go源碼中關於系統調用的定義如下:
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
其中Syscall和RawSyscall區別在於Syscall開始和結束,分別調用了 runtime 中的進入系統調用和退出系統調用的函數,說明Syscall函數受調度器控制,不會造成系統堵塞,而RawSyscall函數沒有調用runtime,因此可能會造成堵塞,一般我們使用Syscall就可以了,RawSyscall最好用在不會堵塞的情況下。
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
Syscall 的定義位於 src/syscall/asm_linux_amd64.s, 是用匯編寫成的,封裝了對linux底層的調用。接收4個參數,其中trap為中斷信號,a1,a2,a3為底層調用函數對應的參數
舉例說明:Go調用底層ioctl函數
trap中斷類型傳入syscall.SYS_IOCTL,SYS_IOCTL中斷號表示調用linux底層ioctl函數
Syscall函數中剩下三個參數a1,a2,a3分別對應ioctl的三個參數。可以man命令查看linux ioctl函數參數,如下
int ioctl(int d, int request, ...);
第一個參數d指定一個由open/socket創建的文件描述符,即socket套接字
第二個參數request指定操作的類型,即對該文件描述符執行何種操作,設備相關的請求的代碼
第三個參數為一塊內存區域,通常依賴於request指定的操作類型
具體過程如下:
1 通過socket創建套接字
2 初始化struct ifconf與/或struct ifreq結構
3 調用ioctl函數,執行相應類型的SIO操作
4 獲取返回至truct ifconf與/或struct ifreq結構中的相關信息
調用底層socket函數創建socket套接字,linux下用man命令查看socket函數用法
int socket(int domain, int type, int protocol);
其中domain為協議類型,type為套接字類型,protocol指定某個協議類型常值
domain的值有:
AF_INET IPv4協議
AF_INET6 Ipv6協議
AF_ROUTE 路由套接字
...
type的值有:
SOCK_STREAM 字節流套接字
SOCK_DGRAM 數據報套接字
SOCK_RAW 原始套接字
...
protocol的值有:
IPPROTO_IP IP傳輸協議
IPPROTO_TCP TCP傳輸協議
IPPROTO_UDP UDP傳輸協議
...
因此linux下調用socket生成套接字寫法:
fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
綜上,轉換成go語言中系統調用寫法
fd, _, err := syscall.RawSyscall(syscall.SYS_SOCKET, syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_IP)
此時即生成了的socket套接字fd
我們傳給int ioctl(int d, int request, …);函數作為第一個參數,第二個參數request操作的類型我們傳入SIOCETHTOOL,獲取ethtool信息
SIOCETHTOOL 在源碼中宏定義為
#define SIOCETHTOOL 0x8946
第三個參數為struct ifreq結構內存地址
Struct ifreq結構如下:
Struct ifreq{ Char ifr_name[IFNAMSIZ]; Union{ Struct sockaddr ifru_addr; Struct sockaddr ifru_dstaddr; Struct sockaddr ifru_broadaddr; Struct sockaddr ifru_netmask; Struct sockaddr ifru_hwaddr; Short ifru_flags; Int ifru_metric; Caddr_t ifru_data; }ifr_ifru; }; #define ifr_addr ifr_ifru.ifru_addr #define ifr_broadaddr ifr_ifru.ifru_broadadd #define ifr_hwaddr ifr_ifru_hwaddr
綜上,linux調用ioctl函數如下:
fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); ioctl(fd, SIOCETHTOOL, &ifreq);
go語言:
fd, _, err := syscall.RawSyscall(syscall.SYS_SOCKET, syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_IP) if err != 0 { return syscall.Errno(err) } _, _, ep := syscall.Syscall(syscall.SYS_IOCTL, uintptr(e.fd), SIOCETHTOOL, uintptr(unsafe.Pointer(&ifreq))) if ep != 0 { return syscall.Errno(ep) }
和調度的交互
這里只列出Syscall和RawSyscall的源碼:
//Syscall TEXT ·Syscall(SB),NOSPLIT,$0-56 CALL runtime·entersyscall(SB) MOVQ a1+8(FP), DI MOVQ a2+16(FP), SI MOVQ a3+24(FP), DX MOVQ $0, R10 MOVQ $0, R8 MOVQ $0, R9 MOVQ trap+0(FP), AX // syscall entry SYSCALL CMPQ AX, $0xfffffffffffff001 JLS ok MOVQ $-1, r1+32(FP) MOVQ $0, r2+40(FP) NEGQ AX MOVQ AX, err+48(FP) CALL runtime·exitsyscall(SB) RET ok: MOVQ AX, r1+32(FP) MOVQ DX, r2+40(FP) MOVQ $0, err+48(FP) CALL runtime·exitsyscall(SB) RET
//RawSyscall TEXT ·RawSyscall(SB),NOSPLIT,$0-56 MOVQ a1+8(FP), DI MOVQ a2+16(FP), SI MOVQ a3+24(FP), DX MOVQ $0, R10 MOVQ $0, R8 MOVQ $0, R9 MOVQ trap+0(FP), AX // syscall entry SYSCALL CMPQ AX, $0xfffffffffffff001 JLS ok1 MOVQ $-1, r1+32(FP) MOVQ $0, r2+40(FP) NEGQ AX MOVQ AX, err+48(FP) RET ok1: MOVQ AX, r1+32(FP) MOVQ DX, r2+40(FP) MOVQ $0, err+48(FP) RET
Syscall和RawSyscall的實現比較典型,可以看到這兩個實現最主要的區別在於:
Syscall在進入系統調用的時候,調用了runtime·entersyscall(SB)函數,在結束系統調用的時候調用了runtime·exitsyscall(SB)。做到進入和退出syscall的時候通知runtime。
這兩個函數runtime·entersyscall和runtime·exitsyscall的實現在proc.go文件里面。其實在runtime·entersyscall函數里面,通知系統調用時候,是會將g的M的P解綁,P可以去繼續獲取M執行其余的g,這樣提升效率。
所以如果用戶代碼使用了 RawSyscall 來做一些阻塞的系統調用,是有可能阻塞其它的 g 的。RawSyscall 只是為了在執行那些一定不會阻塞的系統調用時,能節省兩次對 runtime 的函數調用消耗。
runtime·entersyscall和runtime·exitsyscall這兩個函數也是與scheduler交互的地方,后面會對源碼進行分析
運行時支持
我們之前講了很多次,Go語言runtime為了實現較高的並發度,對OS系統調用做了一些優化,主要就體現在runtime·entersyscall
和入runtime·exitsyscall
這兩個函數上,它們的實現代碼在src/pkg/runtime/proc.c
之中,之前我們已經多次討論過這個文件了。
在分析實現代前,我們先來看看函數的聲明,位置在src/pkg/runtime/runtime.h
中:
|
這里聲明了3個函數,多了一個void runtime·entersyscallblock(void)
,在后面會分析它的功能和使用情況。
好了,現在來看實現代碼。首先,我們很容易找到了void runtime·exitsyscall(void)
的實現,而另外兩個卻找不到,只是找到了兩個與之向接近的函數定義,分別是:
|
通過反匯編分析,我發現代碼中所有對runtime·entersyscall
和runtime·entersyscallblock
的調用最后都分別映射到了·entersyscall
和·entersyscallblock
,也就是說前面兩個函數分別是后面兩個函數的別名。至於為什么這樣實現,我沒有找到相關的文檔說明,但感覺應該主要是由於前后兩組函數參數不同的關系 —— 函數調用本身是不需要傳入參數的,而函數實現時,無中生有了一個dummy
參數,其目的就是為了通過該參數指針(地址)方便定位調用者的PC和SP值。
runtime·entersyscall
好了,我們回到函數實現分析上來,看看進入系統調用前,runtime究竟都做了那些特別處理。下面將這個函數分成3段進行分析:
-
首先,函數通過“pragma”將該函數聲明為“NOSPLIT”,令其中的函數調用不觸發棧擴展檢查。
剛進入函數,先禁止搶占,然后通過
dummy
參數獲得調用者的SP和PC值(通過save
函數保存到g->sched.sp
和g->sched.pc
),將其分別保存到groutine的syscallsp
和syscallpc
字段,同時記錄的字段還有syscallstack
和syscallguard
。這些字段的功能主要是使得垃圾收集器明確棧分析的邊界 —— 對於正在進行系統調用的任務,只對其進入系統調用前的棧進行“標記-清除”。(實際上,Go語言的cgo機制也利用了entersyscall
,因而cgo運行的代碼不受垃圾收集機制管理。)然后,Goroutine的狀態切換到
Gsyscall
狀態。
|
- 下面的代碼是喚醒runtime的后台監控線程
sysmon
,在之前講調度器的時候說過,sysmon
會監控所有執行syscall的線程M,一旦超過某個時間閾值,就將該M與對應的P解耦。
|
-
將M的
mcache
字段置空,並將P的m
字段置空,將P的狀態切換到Psyscall
(注意,與G類似,P也存在若干狀態的切換,Psyscall
和Pgcstop
都是其中的狀態)。檢查系統此刻是否需要進行“垃圾收集”,注意,syscall和gc是可以並行執行的。
由於處於syscall狀態的任務是不能進行棧分裂的,因此通過
g->stackguard0 = StackPreempt
使得后續操作時,一旦出現意外調用了棧分裂操作,都會進入 runtime的morestack
函數並捕獲到錯誤。最后別忘記重新使能任務搶占。
|
這里提一個問題:為什么每次調用runtime·lock(&runtime.sched)
及runtime·unlock(&runtime·sched)
后,都要重新調用save
保存SP和PC值呢?
runtime·entersyscallblock
與 ·entersyscall
函數不同,·entersyscallblock
在一開始就認為當前執行的syscall 會執行一個相對比較長的時間,因此在進入該函數后,就進行了M和P的解耦操作,無需等待sysmon
處理。
- 該函數第一部分與
·entersyscall
函數類似:
|
- 后面的部分就不太一樣了,基本上就是直接將當前M與P解耦,P重新回到
Pidle
狀態。
|
前面說過,所有syscall
包中的系統調用封裝都只調用了runtime·entersyscall
,那么runtime·entersyscallblock
的使用場景是什么呢?
通過查找,發現Go1.2中,僅有的一處對runtime·entersyscallblock
的使用來自bool runtime.notetsleepg(Note *n, int64 ns)
中(當然,針對不同的OS平台有Futex和Sema兩種不同的實現)。Note
類型在Go中主要提供一種“通知-喚醒”機制,有點類似PThread中的“條件變量”。 為了實現高並發度,Go不但實現了線程級的阻塞,還提供了Goroutine級阻塞,使得一個運行的Goroutine也可以阻塞在一個Note
上 —— 對應的P會解耦釋放,因此系統整體並發性不會收到影響。
上述機制在runtime中多有使用,比如在“定時器”模塊中 —— 后面有機會會詳細介紹。
runtime·exitsyscall
該函數主要的功能是從syscall狀態恢復,其結構比較清晰,主要分為兩個步驟:
- 嘗試調用
exitsyscallfast
函數,假設對應的M與P沒有完全解耦,那么該操作會重新將M與P綁定;否則嘗試獲取另一個空閑的P並與當前M綁定。如果綁定成功,返回true
,否則返回false
,留待runtime·exitsyscall
做后續處理。 代碼如下:
|
- 如果
exitsyscallfast
函數失敗,則需要將當前的groutine放回到任務隊列中等待被其他“M&P”調度執行,通過上一講我們知道,類似的操作必須在g0的棧上執行,因此需要使用runtime.mcall
來完成,代碼如下:
|
- 我們再仔細看看
exitsyscall0
的實現,和runtime的其他部分類似,M對於放棄執行總是有點不太情願,所以首先還是會先看看有沒有空閑的P,如果還是沒有,只好將groutine放回全局任務隊列中,如果當前M與G是綁定的,那M必須阻塞直到有空閑P可用才能被喚醒執行;如果M沒有與G綁定,則M線程結束。 最后,當這個goroutine被再次調度執行時,會返回到runtime.mcall
調用后的代碼處,做一些后續的清理工作 —— 將syscallstack
和syscallsp
字段清楚以保證GC的正確執行;對P的syscalltick
字段增1。
一點說明
Go語言之所以設計了M及P這兩個概念,並對執行syscall的線程進行特別處理,適當進行M和P的解耦,主要是為了提高並發度,降低頻繁、長時間的阻塞syscall帶來的問題。但是必須意識到,這種機制本身也存在一定的開銷,比如任務遷移可能影響CACHE、TLB的性能。
所以在實現中,並非所有的系統調用之前都會先調用·entersyscall
。
對於runtime中的一些底層syscall,比如所有的底層鎖操作 —— 在Linux中使用的是Futex機制 —— 相應的Lock/Unlock操作都使用了底層系統調用,此時線程會直接調用syscall而不需要其他的操作,這樣主要是保證底層代碼的高效執行。
一些不容易造成執行線程阻塞的系統調用,在Go的syscall
包中,通過RawSyscall
進行封裝,也不會調用runtime·entersyscall
和runtime·exitsyscall