在討論Go編程語言時(shí)醉箕,經(jīng)常被提起的一個(gè)特點(diǎn)是使用goroutines羽德;這是一種輕量級進(jìn)程父叙,可以并發(fā)運(yùn)行成千上萬的goroutines神郊。許多其它編程語言使用操作系統(tǒng)提供的線程來支持并發(fā)任務(wù)。線程的缺點(diǎn)是它們是比較重的趾唱,因此只能運(yùn)行數(shù)百個(gè)線程涌乳,然后才會(huì)遇到可擴(kuò)展性問題。這些問題在實(shí)時(shí)更新與大量客戶端場景下尤為明顯甜癞。
通常夕晓,我們可以人云亦云地說:goroutine是線程的"輕量級"的版本。但如何才能在功能不變的情況下悠咱,做到輕量級呢蒸辆。我最終深入到Go的runtime
源代碼中去尋找答案。在這篇文章中析既,我將通過實(shí)現(xiàn)一個(gè)簡單的Go程序來展示Go的調(diào)度機(jī)制是如何工作的躬贡。
任務(wù)調(diào)度
Goroutines是建立在事件驅(qū)動(dòng)的架構(gòu)上。當(dāng)一個(gè)事件發(fā)生時(shí)眼坏,與該事件相關(guān)的任務(wù)會(huì)被放在一個(gè)隊(duì)列中拂玻。事件循環(huán)通過隊(duì)列,逐一執(zhí)行任務(wù)。如果觸發(fā)的任務(wù)需要很長時(shí)間才能執(zhí)行檐蚜,會(huì)怎么樣呢魄懂?那么隊(duì)列上的其他事件都會(huì)被阻塞。這不正是我們要使用多線程的原因闯第,這樣才能保證及時(shí)響應(yīng)嗎逢渔?如果某個(gè)任務(wù)占用處理器的時(shí)間過長,這個(gè)線程就會(huì)被調(diào)度器打斷乡括,進(jìn)而讓其他線程去做他們的任務(wù)肃廓。問題是,我們得到的吞吐量比較低诲泌,因?yàn)槲覀冊谇袚Q任務(wù)的時(shí)候盲赊,必須花時(shí)間把半成品收起來。舉例來說敷扫,保存半成品的工作可以是例如我們想要一起做乘法的變量哀蘑,并且是占用幾個(gè)CPU寄存器。在這種情況下葵第,我們將不得不來回交換所有這些寄存器绘迁。
Goroutines試圖通過讓任務(wù)在適當(dāng)?shù)臅r(shí)候調(diào)用調(diào)度器本身來解決事件驅(qū)動(dòng)方式的阻塞問題。這通常發(fā)生在任務(wù)必須等待一些輸入或輸出而又無事可做的時(shí)候卒密。在Go 1.2中缀台,函數(shù)調(diào)用也會(huì)觸發(fā)調(diào)度器,因?yàn)闊o論如何都要把CPU寄存器交給調(diào)用方哮奇。Go還通過在不同的CPU核上運(yùn)行并行事件循環(huán)來降低阻塞的風(fēng)險(xiǎn)膛腐,但我們在這里就不提了。
Echoserver示例
讓我們從一個(gè)簡單的服務(wù)器開始鼎俘,為每個(gè)新的TCP連接啟動(dòng)一個(gè)goroutine哲身。為了簡潔起見,這里省略了錯(cuò)誤處理贸伐,但你可以在我的Github repo上找到完整的代碼勘天。
func main() {
addr, _ := net.ResolveTCPAddr("tcp", ":7777")
listener, _ := net.ListenTCP("tcp", addr)
replyGoroutine(listener)
}
func replyGoroutine(listener net.Listener) {
for {
conn, _ := listener.Accept()
go func() {
buf := make([]byte, 16)
conn.Read(buf)
log.Printf("received: %s", buf)
conn.Write(bytes.ToUpper(buf))
conn.Close()
}()
}
}
所有的Accept()
、Read()
和Write()
調(diào)用都是在等待一些外部操作的完成捉邢,等待時(shí)恰好是切換到另一個(gè)任務(wù)的最佳時(shí)機(jī)脯丝。在等待點(diǎn)調(diào)用調(diào)度器runtime.Gosched()
,這樣進(jìn)程就可以切換到另一個(gè)有工作要做的goroutine歌逢。
我們可以在Bash中使用Netcat來測試這段代碼巾钉。
$ echo "Hello World" | nc localhost 7777
HELLO WORLD
不用goroutine
上面,我們已經(jīng)滿足于 "等待某個(gè)外部操作完成 "時(shí)做其他事情的解釋秘案。但這究竟是如何工作的呢砰苍?為了充分理解這一點(diǎn)潦匈,我們必須深入研究UNIX的polling和文件描述符的工作原理。我們通過在上面的同一個(gè)例子中使用它們赚导,并自己實(shí)現(xiàn)我們自己的偽goroutine的調(diào)度邏輯茬缩。
文件描述符是一種可以處理外部資源的輸入、輸出和其他相關(guān)操作的資源吼旧。它們在讀/寫文件時(shí)使用凰锡,也可以用于在TCP端口上監(jiān)聽新的客戶端和處理一個(gè)開放的TCP連接。我們可以通過UNIX中的accept()
圈暗、read()
和write()
等函數(shù)來訪問這些資源掂为。問題是這些函數(shù)一次只能處理一個(gè)資源。幸運(yùn)的是员串,我們可以使用UNIX的polling來同時(shí)觀察多個(gè)資源上的事件勇哗。
在我們的例子中,我們使用的是epoll系統(tǒng)調(diào)用寸齐,它只支持Linux欲诺。Go運(yùn)行時(shí)以類似的方式使用相同的系統(tǒng)調(diào)用。在Go中渺鹦,你可以通過golang.org/x/sys/unix
包來訪問系統(tǒng)調(diào)用扰法。
type GoroutineState struct {
connFile *os.File
buffer []byte
}
與每個(gè)goroutine相關(guān)的變量是用我們的GoroutineState來模擬的,它以TCP連接的文件描述符為鍵存儲(chǔ)在一個(gè)map中毅厚。
接下來塞颁,我們用EpollWait()
實(shí)現(xiàn)事件循環(huán),在這里卧斟,它監(jiān)視來自TCP監(jiān)聽器和TCP連接的文件描述符事件殴边。EpollCtl()
用于改變監(jiān)視事件的資源集≌溆铮可以在Github repo查看完整的代碼,了解完整的錯(cuò)誤處理竖幔。
func replierPoll(listener *net.TCPListener) {
epollFd, _ := unix.EpollCreate(8)
// UNIX represents a TCP listener socket as a file
listenerFile, _ := listener.File()
// Add the TCP listener to the set of file descriptors being polled
listenerPoll := unix.EpollEvent{
Fd: int32(listenerFile.Fd()),
Events: unix.POLLIN, // POLLIN triggers on accept()
Pad: 0, // Arbitary data
}
unix.EpollCtl(epollFd, unix.EPOLL_CTL_ADD, int(listenerPoll.Fd), &listenerPoll)
// Map EpollEvent.Pad to the connection state
states := map[int]*GoroutineState{}
for {
// Wait infinitely until at least one new event is happening
var eventsBuf [10]unix.EpollEvent
unix.EpollWait(epollFd, eventsBuf[:], -1)
// Go though every event occured; most often len(eventsBuf) == 1
for _, event := range eventsBuf {
if event.Fd == listenerPoll.Fd {
// Handle new connection
// AcceptTCP() will now return immediately
conn, _ := listener.AcceptTCP()
// Equal to creating a new goroutine
newState := addNewClientPoll(epollFd, conn)
fd := int(newState.connFile.Fd())
states[fd] = newState
continue
}
// Handle existing connection
fd := int(event.Pad)
state := states[fd]
if event.Events == unix.POLLIN {
state.connFile.Read(state.buffer)
log.Printf("received: %s", state.buffer)
// Equal to switching away the goroutine
newPoll := event
newPoll.Events = unix.POLLOUT
unix.EpollCtl(epollFd, unix.EPOLL_CTL_MOD, fd, &newPoll)
} else if event.Events == unix.POLLOUT {
state.connFile.Write(bytes.ToUpper(state.buffer))
state.connFile.Close()
// Equal to stopping the goroutine
unix.EpollCtl(epollFd, unix.EPOLL_CTL_DEL, fd, nil)
delete(states, fd)
}
}
}
}
我們的事件循環(huán)正在等待三種類型的事件板乙。
- 新的連接: TCP端口監(jiān)聽器觸發(fā)POLLIN事件,
AcceptTCP()
則立即返回 - 從TCP客戶端接收數(shù)據(jù): 客戶端套接字觸發(fā)POLLIN事件拳氢,
Read()
立即返回募逞。 - 緩沖區(qū)中的可用空間要發(fā)送給TCP客戶端: 客戶端套接字觸發(fā)POLLOUT事件,
Write()
立即返回馋评。
這里是將GoroutineState添加到新的連接中的代碼:
func addNewClientPoll(epollFd int, conn *net.TCPConn) *GoroutineState {
connFile, _ := conn.File()
conn.Close() // Close this an use the connFile copy instead
newState := GoroutineState{
connFile: connFile,
buffer: make([]byte, 16),
}
fd := int(connFile.Fd())
connPoll := unix.EpollEvent{
Fd: int32(fd),
Events: unix.POLLIN, // POLLIN triggers on accept()
Pad: int32(fd), // So we can find states[fd] when triggered
}
unix.EpollCtl(epollFd, unix.EPOLL_CTL_ADD, fd, &connPoll)
return &newState
}
epoll只支持Linux放接。但在其他操作系統(tǒng)上也可以找到類似的系統(tǒng)調(diào)用,比如Mac/BSD上的kqueue()和POSIX系統(tǒng)上擴(kuò)展性較差的poll()留特。
結(jié)論
我們可以看到纠脾,Go使用了事件驅(qū)動(dòng)架構(gòu)的技術(shù)玛瘸,而程序員不必了解它。JavaScript/Node.js也遵循了類似的做法苟蹈,但需要程序員為其編寫代碼糊渊,并思考潛在的阻塞問題。在Go中慧脱,你很少需要思考這個(gè)問題渺绒。從我們的例子中,我們還可以看到菱鸥,訪問UNIX系統(tǒng)調(diào)用非常容易宗兼,因?yàn)閡nix包提供了一個(gè)友好的封閉以供在Go編程中調(diào)用。
(旁注:線程切換是由OS完成氮采,調(diào)度也是OS來做针炉,上下文切換費(fèi)時(shí)費(fèi)力;go有自己的調(diào)度器扳抽,對go來說篡帕,goroutine是調(diào)度單元,goroutine切換也是在用戶態(tài)完成贸呢,goroutine需要OS線程來最終運(yùn)行镰烧,所以可以盡可能利用CPU,從編碼角度講楞陷,goroutine當(dāng)然比線程輕量怔鳖,畢竟同樣功能代碼量更少,go的runtime幫程序員完成了很多事情固蛾。作者的切入點(diǎn)很有意思结执,看起來像是在做一個(gè)代碼級別的比較,其實(shí)引申出了很多OS底層內(nèi)容艾凯,比如事件驅(qū)動(dòng))
參考
- How to stop Go’s scheduler loop from working: A pitfall of golang scheduler
- Deeper coverage of the scheduler concepts: Analysis of the Go runtime scheduler