入吾 Go 中:走訪 Go 語言內部實作

說到 Go 語言,你會想到什麼呢? 簡潔?美麗?強大?無所不在?從筆者的角度來看,這些都是 Go 語言的一些形容詞而已。 「存在先於本質」,可是,Go 語言到底是什麼?這個問題可能又不免太大了。 秉持著鐵人賽的精神,我們就從 Hello World 開始吧!在接下來的 30 天當中,筆者將使用靜態的 vim-go 與動態的 gdb 追蹤工具觀察 Go 語言程式,目標是理解重要標準函式庫的實作並妥善的解說給讀者;如果能夠來得及的話,也希望能夠涵蓋到其他面向,比方說 Go compiler 之類的。 讓我們一起努力吧!
來源:https://ithelp.ithome.com.tw/users/20103524/ironman/2589
文章數:30
目錄
- Day 01 第一天:本系列方向與寫作計畫
- Day 02 第二天:進入 Hello World!
- Day 03 第三天:追蹤 os.Stdout
- Day 04 第四天:拆解 Println
- Day 05 第五天:Fprintln 後半
- Day 06 第六天:暫停一下回顧未解問題
- Day 07 第七天:瀏覽系統相依的初始化
- Day 08 第八天:進入 schedinit
- Day 09 第九天:進入 schedinit (之二)
- Day 10 第十天:初遇 GO 語言密碼:G、M、P?
- Day 11 第十一天:繼續奮戰 schedinit
- Day 12 第十二天:簡單除錯 GO 語言程式
- Day 13 第十三天:更多除錯訊息
- Day 14 第十四天:schedinit 告一段落
- Day 15 第十五天:追蹤 newproc
- Day 16 第十六天:newproc1 之前的堆疊準備動作
- Day 17 第十七天:看看 systemstack 函式呼叫
- Day 18 第十八天:GO 語言運行模型的三項之力
- Day 19 第十九天:G 的取得路徑
- Day 20 第二十天:新生 goroutine 的初始狀態
- Day 21 第二十一天:配置新的 goroutine
- Day 22 第二十二天:領取號碼牌
- Day 23 第二十三天:開始排隊
- Day 24 第二十四天:上膛的 goroutine
- Day 25 第二十五天:minit 與 signal 設置
- Day 26 第二十六天:signal 初始化收尾
- Day 27 第二十七天:goroutine 執行中
- Day 28 第二十八天:其他的 M 登場
- Day 29 第二十九天:終點的 main.main
- Day 30 第三十天:繼續前進
第一天:本系列方向與寫作計畫
- Day: 1
- 發佈日期: 2019-09-16
- 原文: https://ithelp.ithome.com.tw/articles/10215966
開場介紹
GO 語言是由 Rob Pike 與 Ken Tompson 兩位 UNIX 作業系統開發者於 2009 九年開始發起的一項開放原始碼計畫。這些主力開發者們大幅引用過去的經驗設計出這個新的語言,具有以下特性:
- 強型別、編譯型語言:效能佳
- 有垃圾回收機制:不需要操煩容易出 bug 的記憶體管理
- 部署簡單快速:預設靜態連結
- 明確規定語言風格典範:不易出現社群聖戰
- 內建測試框架
- 內建同步性操作
- 民主且活躍的社群經營
GO 語言在 2013 年 docker 專案問世之後獲得空前的成功;2016 年獲得 TIOBE 指數給予年度最佳程式語言獎項;現在的殺手級應用 K8S更是所有 IT 人員都感興趣的強大工具。這都顯示使用 GO 語言建構大型系統的便利與快捷。
所以筆者這次的鐵人賽挑戰要帶給 IT 邦幫忙的網友們一串 GO 語言的教學文...嗎?且先讓我們回顧歷年鐵人賽有哪些 GO 語言相關的文章吧:
就連今年目前也已經至少有以下幾篇
所以,不管是語言的學習本身或是語言的應用面,我們都已經有了這些前人的教學,那麼筆者又何必多此一舉重新分享安裝、Hello World、語法、演算法簡單實作、小型專案...的流程呢?因此,筆者決定探究的主題是至今比較少網友曾經訂過得目標,也就是研究** GO 語言是如何實作出來的?**
如此一來與前人的努力便不顯重複,也能夠提供其他的資訊。
然而,難道這個題目就真的那麼新鮮嗎?也不盡然。今年的 COSCUP 就有一位 Ken-Yi Lee 大大給了一個演講「從原始碼看 GO 語言的排程與實現」,條理分明,推薦各位讀者閱讀!但是筆者是比較土法煉鋼的方式在且戰且走,與往年的風格不會相去太遠。
寫作計畫
其實筆者也是且戰且走的在準備這系列,對筆者來說這是全新的挑戰,內心也是非常期待。雖然根據往年經驗,預先建立目標也不一定能夠成功符合預期地完成,但這裡還是列出以下幾個大方向:
- 基本架構:一個 GO 語言的 Hello World 會變成什麼樣子?怎麼變的?記憶體 layout 是怎麼樣?參數怎麼傳遞?
- map 與 slice 等複合式資料型別的實作
- goroutine 與 channel 等同步性的實作
- compiler 與 linker 等工具鏈的實作
- 架構相依性的移植部份的實作
這些題目都非常大,承諾要將之全部完成顯然不切實際,因此實際進度會隨著寫作情況調整,還請各位網友海涵!但筆者承諾絕對盡筆者所能來探究 GO 語言的核心實作。當然,這系列也不能為了 GO 語言初學者從零開始,所以目標客群有一些基本條件:
- 有過一些 GO 語言經驗
- 如果完全沒有,至少要有一門精熟的程式語言
- 讀過「Binary Hacks」或是「程式設計師的自我修養」之類的書者佳
環境架設
為了讓有興趣的讀者諸君能夠一同體會這個主題的樂趣,筆者在這開張的第一日就一起介紹所需要的開發環境。首先複製專案:
$ git clone https://github.com/golang/go.git
$ cd go
$ GOOS=Linux GOARCH=amd64 ./make.bash
請視情況調整所需指定的作業系統與處理器架構。
如此一來我們就有一個實驗用的 GO 語言環境啦!明天將開始我們的追蹤之旅,各位讀者我們明日再會!
第二天:進入 Hello World!
- Day: 2
- 發佈日期: 2019-09-17
- 原文: https://ithelp.ithome.com.tw/articles/10216651
前情提要
昨日開場介紹了 GO 語言以及本系列的目標,也用最懶人的方式編好了一個實驗環境,但是那個環境在哪裡呢?作日最後的進度是:
$ GOOS=Linux GOARCH=amd64 ./make.bash
當前目錄是才剛透過 git clone 下來的 go 目錄。這個建置指令成功之後,產出將會存在於上一層目錄下的 go-linux-amd64-bootstrap。GO 語言的標準函式庫與工具包都會在那底下。
本系列之後的文章中,都會用
$GOROOT來代表這個目錄。
記得把
$GOROOT/bin加到PATH環境變數裡面,否則無法使用編輯出來的go指令喔!
範例 Hello World 程式
那麼我們就直接來追蹤最簡單的程式:Hello World吧!
package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
至於程式碼追蹤的環境架設該怎麼辦呢?筆者自己是使用 vim-go 這個工具,因為它同時具備良好的教學文件,對於熟悉 vim+cscope 開發環境的人來說非常容易上手。如果各位讀者有需求,請留言於下,筆者會擇日安插相關的內容。
建置且運行指令如下:
$ go build hw.go
$ ./hw
Hello World!
fmt.Println 函式
若讀者成功設定了自動跳轉功能,你可能會發現跳轉的目的地是系統使用的 GO 語言環境,而不是我們在前一日建置得到的環境,這該怎麼辦呢?答案是
GOROOT環境變數。筆者的環境中,就是將GOROOT指定為/home/xxxx/go-linux-amd64-bootstrap。
函式 Println 屬於函式庫 fmt,在 $GOROOT/src/fmt/print.go 之中:
// Println formats using the default formats for its operands and writes to standard output.
// Spaces are always added between operands and a newline is appended.
// It returns the number of bytes written and any write error encountered.
func Println(a ...interface{}) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
輸入是不定個萬用型別的參數,輸出則是印出的位元組數與一個錯誤值。我們可以看見這單純是一個 Fprintln 的 wrapper,就和 C 語言中的 printf 和 fprintf 的關係類似,並將輸出方向導向 os.Stdout 去。
os.Stdout 變數
筆者本來想跳過這個顯而易見代表著標準輸出的東西,畢竟,這有什麼大不了的?C 函式庫就已經把 FILE* stdout 定義起來了。但是進去看之後發現,這個變數被定義為:
// Stdin, Stdout, and Stderr are open Files pointing to the standard input,
// standard output, and standard error file descriptors.
//
// Note that the Go runtime writes to standard error for panics and crashes;
// closing Stderr may cause those messages to go elsewhere, perhaps
// to a file opened later.
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
Stderr = NewFile(uintptr(syscall.Stdout), "/dev/stderr")
)
簡單追一下 NewFile 這個呼叫,後面東西也是蠻多的(畢竟函式都叫做 NewFile了),覺得顯然不對!像是被閃電擊中一樣。如果每一次呼叫 Println 就必須要跑一次這一連串的過程,那怎麼會合理呢?
對於 Unix-like 系統來講,標準輸出就是對應到 file descriptor 的 1 去而已,理論上將檔案描述子對應到一個檔案物件的功夫應該只要作一次就夠了才對。但是按照這份程式碼字面上看起來,就像是每一次呼叫 fmt.Println 就會呼叫到這一些 NewFile 一樣。怎麼回事?
使用 gdb 動態追蹤
為了解決這個困擾,筆者決定還是先引入 gdb 除錯工具,觀察這個 NewFile 函式到底是誰來呼叫的。
對於 gdb 不熟的讀者,有問題請多發問喔!用起來沒那麼難,筆者也會附上最基本的解說。
首先我們直接把程式叫起來監控(-d 是為了讓 gdb 能夠抓到非使用者撰寫的、函式庫部份的索引):
$ gdb ./hw -d $GOROOT
如果你的 gdb 跳出一些訊息類似
...
Reading symbols from hw...done.
warning: File "/home/noner/FOSS/2019ITMAN/go/src/runtime/runtime-gdb.py" auto-loading has been declined by your `auto-load safe-path' set to "$debugdir:$datadir/auto-load".
To enable execution of this file add
add-auto-load-safe-path /home/noner/FOSS/2019ITMAN/go/src/runtime/runtime-gdb.py
line to your configuration file "/home/noner/.gdbinit".
To completely disable this security protection add
set auto-load safe-path /
line to your configuration file "/home/noner/.gdbinit".
For more information about this security protection see the
"Auto-loading safe path" section in the GDB manual. E.g., run from the shell:
info "(gdb)Auto-loading safe path"
那麼就按照他的指示給予 gdb 所需要的 python script 路徑:
(gdb) add-auto-load-safe-path /home/noner/FOSS/2019ITMAN/go/src/runtime/runtime-gdb.py
又,NewFile 該怎麼找呢?對於使用 gdb 除錯 C 的朋友來說這裡有一個需要注意的部份,那就是 GO 語言有函式庫的機制,所以內部的函式的全域名稱會在函式名前方冠上函式庫名稱。所以我們想要關注的就是 os.NewFile 和 main.main 兩個函式的先後順序。(b 指令代表我們想要在哪個位置設定中斷點)
(gdb) b os.NewFile
Breakpoint 1 at 0x462730: file /home/noner/FOSS/2019ITMAN/go/src/os/file_unix.go, line 81.
(gdb) b main.main
Breakpoint 2 at 0x483f60: file /home/noner/FOSS/2019ITMAN/go_internal/hw.go, line 8.
(gdb) run
至此,程式開始運行。可以使用
(gdb) c
代表 continue 指令繼續程式本身的執行流程。
實驗結果
結果,os.NewFile 早在 main.main 執行之前就已經執行到了,因為 os.NewFile 先停了下來。如果使用 backtrace 或是 bt 指令去觀察 os.NewFile 如何被執行到,則會發現:
Thread 1 "hw" hit Breakpoint 1, os.NewFile (fd=<optimized out>, name=..., ~r2=<optimized out>)
at /home/noner/FOSS/2019ITMAN/go/src/os/file_unix.go:81
81 func NewFile(fd uintptr, name string) *File {
(gdb) bt
#0 os.NewFile (fd=<optimized out>, name=..., ~r2=<optimized out>) at /home/noner/FOSS/2019ITMAN/go/src/os/file_unix.go:81
#1 0x0000000000462fe9 in os.init () at /home/noner/FOSS/2019ITMAN/go/src/os/file.go:59
#2 0x0000000000483d65 in fmt.init () at <autogenerated>:1
#3 0x0000000000484015 in main.init () at <autogenerated>:1
#4 0x00000000004284eb in runtime.main () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:189
#5 0x000000000044ffc1 in runtime.goexit () at /home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:1340
#6 0x0000000000000000 in ?? ()
這個指令的效果是能夠看見執行到目前為止的 call stack,所以顯然是類似建構子的東西幫助我們在 main 函式之前將它初始化了。相對的,當我們後來在 main.main 停下來之時(d 指令代表刪除我們設定的第一個中斷點)
(gdb) d 1
(gdb) c
Continuing.
Thread 1 "hw" hit Breakpoint 2, main.main () at /home/noner/FOSS/2019ITMAN/go_internal/hw.go:5
5 func main() {
(gdb) bt
#0 main.main () at /home/noner/FOSS/2019ITMAN/go_internal/hw.go:5
這裡顯示的歷史卻是 main 函式沒有與建構子分享任何共同的祖先。這可能是事實,也可能僅僅是 gdb 的能力有限,目前對於筆者來說也是個謎。
本日小結
fmt.Println是個 wrapper- 使用 gdb 工具輔助追蹤,且發現到 GO 語言隱式建構子的存在。
各位讀者,我們明日再會!
第三天:追蹤 os.Stdout
- Day: 3
- 發佈日期: 2019-09-18
- 原文: https://ithelp.ithome.com.tw/articles/10216654
前情提要
昨日透過靜態方法(程式碼)與動態方法(gdb 除錯器)雙管齊下,多窺得一些有趣的行為。
os.Stdout 再追蹤
昨日為了驗證這個變數啟用了 gdb,且發現了建構子的存在。 **建構子如何被呼叫?**這樣的問題的確很有趣,但筆者這裡決定以 fmt.Println 的整個功能性為觀察重點,等到結束了之後再回頭追蹤建構子。
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stderr), "/dev/stdout")
Stderr = NewFile(uintptr(syscall.Stdout), "/dev/stderr")
)
NewFile
這個 NewFile 又是何方神聖?它被定義在 $GOROOT/src/os/file_unix.go 裡面:
// NewFile returns a new File with the given file descriptor and
// name. The returned value will be nil if fd is not a valid file
// descriptor. On Unix systems, if the file descriptor is in
// non-blocking mode, NewFile will attempt to return a pollable File
// (one for which the SetDeadline methods work).
func NewFile(fd uintptr, name string) *File {
kind := kindNewFile
if nb, err := unix.IsNonblock(int(fd)); err == nil && nb {
kind = kindNonBlock
}
return newFile(fd, name, kind)
}
這個函式只關心 fd 的性質是否為 non-blocking,而這個判斷又是為了了解該檔案描述子是否為可輪詢(pollable)的。根據 UNIX 的一切皆檔案哲學,可輪詢與否就被藏在檔案這個抽象層之後了。GO 語言有意的突顯這個性質的重要性,也許是因為 GO 語言團隊在設計之初對於網路和非同步事件的意識更強烈的關係?
簡單來說,可以用傳統的
poll()系統呼叫去監控的檔案描述子即是可輪詢的。一般的檔案通常不具備或是沒有必要支援這個性質,而透過socket()系統呼叫取得的網路通訊介面就可以。順帶一題,bash之類的 shell 程式也使用輪詢機制觀察標準輸入的動態。
稍微轉了一手,附加一個 kind 代表這個檔案描述子的形式當作參數,傳下去給非全域可存取的 newFile 函式。
newFile
newFile 位在同一個檔案之中,
// newFile is like NewFile, but if called from OpenFile or Pipe
// (as passed in the kind parameter) it tries to add the file to
// the runtime poller.
func newFile(fd uintptr, name string, kind newFileKind) *File {
fdi := int(fd)
if fdi < 0 {
return nil
}
f := &File{&file{
pfd: poll.FD{
Sysfd: fdi,
IsStream: true,
ZeroReadIsEOF: true,
},
name: name,
stdoutOrErr: fdi == 1 || fdi == 2,
}}
pollable := kind == kindOpenFile || kind == kindPipe || kind == kindNonBlock
...
這裡將一整個 File 結構體設定起來。其中透過強制轉型,將 fd 轉為整數之後儲存在 Sysfd 成員中,我們可以預期這就是之後透過 write() 系統呼叫執行印出動作時所使用的標準輸出檔案描述子,因為在稍早的初始化部份的程式碼中,
Stdout = NewFile(uintptr(syscall.Stderr), "/dev/stdout")
的 syscall.Stdout 就是我們熟悉的 1,也就是標準輸出。這裡的寫法也是十分符合 GO 語言典範的,因為有垃圾回收機制的緣故,先宣告一個靜態的 File 結構體並依需求將之填滿,然後直接回傳其指標,也不必擔心記憶體管理的問題。
中間筆者跳過一段關於作業系統環境的判定,裡面分別針對 FreeBSD 和 Darwin 做特殊處理,這裡就不深入。
File 結構與 poll.FD 結構
File 定義在 src/os/types.go 之中,
type File struct {
*file // os specific
}
只包含了一個作業系統相依的指標,而這個 file 的定義又回到了 src/os/file_unix.go 之中,畢竟因為筆者在 Linux 上實驗:
// file is the real representation of *File.
// The extra level of indirection ensures that no clients of os
// can overwrite this data, which could cause the finalizer
// to close the wrong file descriptor.
type file struct {
pfd poll.FD
name string
dirinfo *dirInfo // nil unless directory being read
nonblock bool // whether we set nonblocking mode
stdoutOrErr bool // whether this is stdout or stderr
}
註解很貼心的說明了為什麼要把
File這個抽象層多定義一個指標。但是這裡又牽涉到finalizer這個對於 C 母語的筆者來講還沒有了解的概念。
poll.FD 結構又是什麼呢?這個名稱代表的是定義在 poll 函式庫的 FD 型別,定義在 src/internal/poll/fd_unix.go 中
// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
...
poll.FD 的實際成員比 newFile 函式使用的部份還要多很多,其中有同步機制需要使用的鎖,以及一些標誌性質用的 flag。單從註解我們可以了解這是網路以及一般檔案的共用界面。但是要真正了解 poll 函式庫的存在意義的話,就必須等到之後再說了。
初始化 f.pfd
...
if err := f.pfd.Init("file", pollable); err != nil {
// An error here indicates a failure to register
// with the netpoll system. That can happen for
// a file descriptor that is not supported by
// epoll/kqueue; for example, disk files on
// GNU/Linux systems. We assume that any real error
// will show up in later I/O.
} else if pollable {
// We successfully registered with netpoll, so put
// the file into nonblocking mode.
if err := syscall.SetNonblock(fdi, true); err == nil {
f.nonblock = true
}
}
這裡的 Init 函式即是初始化 f 這個 File 物件的 pfd 這個 poll.FD 物件的函式。若是初始化順利且所處理的檔案描述子具有可輪詢的性質,則會進入 syscall.SetNonblock 函式,我們可以在 src/syscall/exec_unix.go 中一窺究竟:
func SetNonblock(fd int, nonblocking bool) (err error) {
flag, err := fcntl(fd, F_GETFL, 0)
if err != nil {
return err
}
if nonblocking {
flag |= O_NONBLOCK
} else {
flag &^= O_NONBLOCK
}
_, err = fcntl(fd, F_SETFL, flag)
return err
}
其中,fcntl 會緊接著執行到真實存在於 Linux 系統的 fcntl() 系統呼叫,這裡的格式也與 man 手冊中的
int fcntl(int fildes, int cmd, ...);
相當類似。而在 src/syscall/zsyscall_linux_amd64.go 中,
func fcntl(fd int, cmd int, arg int) (val int, err error) {
r0, _, e1 := Syscall(SYS_FCNTL, uintptr(fd), uintptr(cmd), uintptr(arg))
val = int(r0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}
Syscall 呼叫大概類似於 glibc 的 syscall wrapper。
newFile 收尾
newFile 函式還剩下最後的一行,
...
runtime.SetFinalizer(f.file, (*file).close)
return f
}
SetFinalizer 光是註解就超過六十行,詳細解釋了它的非同步特性。從語意上看來,大致上是要準備解構子的意思,但是這個機制還需要從其他角度進一步探究。
疑問
這是筆者自本日開始的一個新章節,用意是紀錄目前為止觀念上還不清楚的地方。畢竟也是一邊學習一邊準備這個系列,沒有辦法直接解決應該不至於太過分;但也的確有可能直到最後都存在無法回答的問題,到時後再一併整理起來,當作未來的學習方向。
- 所謂的
netpoll系統是指什麼?顯然在創建檔案的時候很重要。 runtime.SetFinalizer是什麼?在整個 GO 語言 runtime 中扮演何種角色?
本日小結
- 看完
os.Stdout標準輸出的生成 - 初遇
File結構、FD結構
各位讀者,我們明日再會!
第四天:拆解 Println
- Day: 4
- 發佈日期: 2019-09-19
- 原文: https://ithelp.ithome.com.tw/articles/10217682
前情提要
昨日多深入一些,理解 os.Stdout 的生成與牽涉到的結構。
回到 fmt.Fprintln
如果讀者跟筆者一樣是從 C 語言過來的,一定也跟筆者一樣覺得單是追蹤 os.Stdout 就已經看到很多 GO 語言神秘之處,明明只是結構體初始化,就已經埋下許多非同步事件的伏筆之類的。不過我們還是先完成整個 Hello World 程式的追蹤吧。
// These routines end in 'ln', do not take a format string,
// always add spaces between operands, and add a newline
// after the last operand.
// Fprintln formats using the default formats for its operands and writes to w.
// Spaces are always added between operands and a newline is appended.
// It returns the number of bytes written and any write error encountered.
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
註解中說,ln 結尾的這些函式不會接收格式字串,也就不會有什麼你出現 %d 我就要幫你替換成一個整數的這種功能;換句話說,這比較接近 C 語言裡面的 puts() 函式,而且最後會換行。
註解又說 Fprintln 會將傳入的參數依預設格式印出至 io.Writer 型態的 w 參數去。預設格式是說,也許今天傳入的不定長度參數中有諸般混雜的型別變數存在,則其實你不需要指定它們所需要的格式字串(%d、%f 之類),GO 語言自然保證它們會依照自身型別的預設格式印出;又 io.Writer 是什麼東西呢?它被定義在 src/io/io.go 之中:
// Writer is the interface that wraps the basic Write method.
//
// Write writes len(p) bytes from p to the underlying data stream.
// It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// Write must return a non-nil error if it returns n < len(p).
// Write must not modify the slice data, even temporarily.
//
// Implementations must not retain p.
type Writer interface {
Write(p []byte) (n int, err error)
}
筆者一直認為介面(interface)的觀念非常魔幻,和物件導向的概念很能夠相輔相成的一種感覺。物件導向是物件為主,包含了成員變數與方法。但是 GO 的介面的使用方式是只定義方法的原型,然後如果你有一個物件有那個方法,就能夠當作是符合該介面的一個物件。以現在的例子來看就是說,也許之後我們可以用 GO 語言寫嵌入式系統的機器人手臂,而這個機器人手臂(RobotArm 物件)內含有一個 Write 方法與 io.Writer 在這裡定義的完全相同,那麼任一個 RobotArm 變數都可以被當作是一個 io.Writer 的介面,因而可以出現:
rh := newRobotArm(...)
fmt.Fprintln(rh, "Hello World!")
這樣的程式碼來讓機器人幫你寫出訊息。
newPrinter 函式
我們接著繼續看:
...
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
newPrinter 函式顯然是接下來的一個重點操作。它被定義在 src/fmt/print.go 之中,
// newPrinter allocates a new pp struct or grabs a cached one.
func newPrinter() *pp {
p := ppFree.Get().(*pp)
p.panicking = false
p.erroring = false
p.fmt.init(&p.buf)
return p
}
這裡的 pp 結構應該是想表達 printer pool 的資源池裡面最小單位。ppFree 的出身也很有趣:
var ppFree = sync.Pool{
New: func() interface{} { return new(pp) },
}
sync.Pool,也就是 sync 函式庫的 Pool 型別的物件。Get() 函式回傳的東西也是萬用的 interface{},所以最後還把他轉回了 *pp 的型態並賦值給 p。Get() 函式內部在現在看來真的蠻嚇人的,裡面引用 internal/race 函式庫,執行像是** pin 住當前 goroutine 使之不要被搶佔(preempt)**的函式(簡直像是在看 kernel code);最後的 init 函式就是將方才自資源池中取得的記憶體空間配給到物件內,然後清空一些既有的性質。
印:doPrintln
...
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
接下來就是要拿不定個數的傳數參數 a,列印到 p.buf 裡面去,這樣下一步才能夠單純透過 w 這個 io.Writer 介面的 Write 方法印出。這個抽象切得很具美感:printer 物件的 p 只管透過自己的資源與傳入參數,構成一塊連續空間的內容;w 是某個具有 Write 方法的不知名物件,它只需要負責操作該物件相關的方法來印出 p.buf。doPrintln 同樣在 src/fmt/print.go 之中:
// doPrintln is like doPrint but always adds a space between arguments
// and a newline after the last argument.
func (p *pp) doPrintln(a []interface{}) {
for argNum, arg := range a {
if argNum > 0 {
p.buf.WriteByte(' ')
}
p.printArg(arg, 'v')
}
p.buf.WriteByte('\n')
}
for ... range 的語法將 a 這個不定長度、不定內容的陣列切分開來。除了第一個元素之外,其餘的都必須前置空格。每個元素以 printArg 函式列印,並且...附帶一個 v 字元?然後最後印出換行符號。所以所有的魔術都在 printArg 裡面了。
這裡只列出一部份程式碼片段,說明其中邏輯:
func (p *pp) printArg(arg interface{}, verb rune) {
...
if arg == nil {
...
// Special processing considerations.
// %T (the value's type) and %p (its address) are special; we always do them first.
switch verb {
...
// Some types can be done without reflection.
switch f := arg.(type) {
case bool:
p.fmtBool(f, verb)
case float32:
p.fmtFloat(float64(f), 32, verb)
case float64:
...
最一開始判斷 arg 參數是否為空,是因為可以視情況給予 <nil> 之類的輸出結果。接下來是特殊格式字串的判定,即是 %T 和 %p 兩項,這兩者都需要特殊的函式來取得想要顯示的值。回頭看看,其實傳入的 v 字元也就是將 Fprintln 的傳入都視為 %v 的格式化字串,也就是按照預設格式輸出的意思。最後是通用的部份,透過 arg 參數的型別來判斷該做什麼樣的格式化。
我們的 Hello World 例子應該會在後面的字串部份進行格式化。
疑問
- 追蹤過程中發現搶佔是可以被關掉的,也就是說 GO 語言有非同步的搶佔引擎。其機制為何?
arg.(type)這種功能被稱作 reflect。GO 語言的 reflect 是怎麼做的?internal/race是怎麼樣的函式庫?功能?sync是怎樣的函式庫?功能?
本日小結
- 看了前半部的
Fprintln函式,也就是整個字串形成的部份。
我們明天再來看 Write 的部份進行哪些操作。感謝各位讀者,我們明天再會!
第五天:Fprintln 後半
- Day: 5
- 發佈日期: 2019-09-20
- 原文: https://ithelp.ithome.com.tw/articles/10218227
前情提要
昨日瀏覽了 fmt.Fprintln 的前半,先是看了一下 printer 代表什麼意義,中間也如往常一般遇到許多新奇又陌生的 GO 語言元件(如 sync.Pool),然後觀察到 p.doPrintln 函式正式將傳入的參數們化為字串。
fmt.Fprintln 後半
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
追蹤到最後可以發現 p.buf 的 buffer 型別,其實是從一般的字元陣列 []byte 而來,相關聯的註解表示是為了避免 bytes.Buffer 帶來的 overhead。不管怎樣,它會被傳入到 w 的 Write 函式去。這個又該怎麼找?
由於我們已經知道這裡傳入的 w 是之前探討過的 os.Stdout,所以我們應該也到 os 底下撈撈看有沒有這個函式。果然可以在 src/os/file.go 裡面找到一個 Write,這裡就請各位讀者相信我,這就是在 Fprintln 裡面會使用到的 Write 函式。
正規來講應該要再開一次 gdb 來觀察,但這裡就先省去那個步驟了。
// Write writes len(b) bytes to the File.
// It returns the number of bytes written and an error, if any.
// Write returns a non-nil error when n != len(b).
func (f *File) Write(b []byte) (n int, err error) {
if err := f.checkValid("write"); err != nil {
return 0, err
}
根據註解,這個 Write 原型定義沒有什麼新消息,本身是 Unix 系統一直以來的樣貌。最一開始針對 f 這個檔案物件執行 checkValid 來做檢查。這個函式定義在 src/os/file_posix.go 裡面:
// checkValid checks whether f is valid for use.
// If not, it returns an appropriate error, perhaps incorporating the operation name op.
func (f *File) checkValid(op string) error {
if f == nil {
return ErrInvalid
}
return nil
}
說實在的,這一段 code 還蠻令人感到傻眼。傳入的 op 根本沒有用處,到底是為了什麼?本來想就這樣跳過,但如果連這樣的問題都迴避了,這一系列文大概也不用混了。所以筆者決定觀察一下開發者的紀錄,找出這一段落之所以變成這樣的原因。
但是要怎麼找呢?單純使用 git blame 指令無法看見已經消失的程式碼,所以這裡使用額外的一個指定行數區間的功能:
$ git blame -L 189,+10 src/os/file_posix.go
c05b06a12d0 (Ian Lance Taylor 2017-02-10 15:17:38 -0800 189) func (f *File) checkValid(op string) error {
c05b06a12d0 (Ian Lance Taylor 2017-02-10 15:17:38 -0800 190) if f == nil {
c05b06a12d0 (Ian Lance Taylor 2017-02-10 15:17:38 -0800 191) return ErrInvalid
c05b06a12d0 (Ian Lance Taylor 2017-02-10 15:17:38 -0800 192) }
c05b06a12d0 (Ian Lance Taylor 2017-02-10 15:17:38 -0800 193) return nil
c05b06a12d0 (Ian Lance Taylor 2017-02-10 15:17:38 -0800 194) }
因為筆者撰寫這一系列文之時,checkValid 函式在 189 行處,因此設定了第 189 行開始,十行的範圍內的 git blame。幸好範圍也沒有很大,結果只有一個 commit 與這個區段的修改有關。在這個 commit 裡面,checkValid 函式自原本存在的 src/os/file.go 裡面刪除掉,而在 src/os/file_posix.go 與 src/os/file_plan9.go 複製了各自一次。然而,當時的 checkValid 函式長成這樣子(節自 git show 的輸出結果):
+func (f *File) checkValid(op string) error {
+ if f == nil {
+ return ErrInvalid
+ }
+ if f.pfd.Sysfd == badFd {
+ return &PathError{op, f.name, ErrClosed}
+ }
+ return nil
+}
在第二個段落,也就是 f 物件不為空,但是他所存的 poll.FD 物件是壞掉的檔案描述子的情況下,回傳了一個特製的錯誤物件。至少,這樣合理多了!因為那個錯誤物件需要使用 op 參數。然而這裡有兩個疑點:
- 為什麼現在沒有第二段的判斷區塊?
- 如果途中有修改過,為什麼沒有出現在剛才的指定區段
git blame之中?
兩個問題其實指向同一個答案,因為在這個 commit 時候,這一塊程式碼區段還不在 189 行起算 10 行的範圍內,所以沒有出現在剛才的 blame 結果之中。參考 git show c05b06a12d0 的結果,可以發現這時候這一塊程式碼還在 144 行起算 29 行的範圍,因此需要再引用一次 git blame -L 追蹤,繁瑣的步驟就略過了,我們發現是在下面這個 commit:
commit 11c7b4491bd2cd1deb7b50433f431be9ced330db
Author: Ian Lance Taylor <iant@golang.org>
Date: Mon Apr 24 21:49:26 2017 -0700
os: fix race between file I/O and Close
Now that the os package uses internal/poll on Unix and Windows systems,
it can rely on internal/poll reference counting to ensure that the
file descriptor is not closed until all I/O is complete
...
回顧一下我們原本是在探討 os.Stdout 這個檔案物件的 Write 成員函式,並且正在觀察它一開始的 checkValid 函式。這個 commit 的標題就說明了被拿掉的第二段判斷的理由:現在已經不需要擔心檔案讀寫與關閉的非同步行為了,這個方面透過 internal/poll 函式庫獲得了功能上的保證(在 Unix 上與 Windows 上都是),所以那個部份就不需要再檢查了。
但是為什麼要留著呢?因為 GO 語言想要在 src/os/file.go 裡面保留原先的介面,這個介面還正在被 Plan9 使用,我們也可以在 src/os/file_plan9.go 裡面看到原先的 checkValid 函式實作,所以筆者在 Linux 平台上會使用到的 file_unix.go 這邊當然也就不便修改函式之間的 API 了。
write 函式
n, e := f.write(b)
if n < 0 {
n = 0
}
if n != len(b) {
err = io.ErrShortWrite
}
為什麼又深入一層呢?從大寫變到小寫是在惡作劇嗎?這其實也是抽象層的概念。我們現在身處的 Write 函式是所有作業系統都共用的 file.go,但是這個小寫的 write 是在 file_unix.go 之中,
// write writes len(b) bytes to the File.
// It returns the number of bytes written and an error, if any.
func (f *File) write(b []byte) (n int, err error) {
n, err = f.pfd.Write(b)
runtime.KeepAlive(f)
return n, err
}
又再度被導到 f.pfd 之前大略觀察過的 poll.FD 物件的 Write 函式去。不僅如此,在這之後又有一個 runtime 函式庫的 KeepAlive 功能,顧名思義是為了讓 f 不至於被 GO 語言執行期的非同步行為處理掉,而特地強調這個檔案物件請務必給我留著的用意;事實上,在這個函式的前後,那些我們都很熟悉的檔案介面操作(read、seek、...)都有一個 runtime.KeepAlive 跟著。
再來看 f.pfd.Write,這被定義在 src/internal/poll/fd_unix.go 之中,這裡就不列出程式碼,只介紹其中做的事情。
- 還記得之前觀察
poll.FD物件時層提過他有一些同步鎖的成員變數嗎?其中有一個寫入鎖就用在頭尾,保護這個Write函式的寫入有獨占性。 - 一個迴圈將傳入的
b透過一個或多個系統呼叫寫到指定的檔案去。是的,就是這裡引用了syscall.Write。但其實這還不是真正的系統呼叫介面,其中還引用了許多race函式庫的功能保護zsyscall_linux_amd64.go裡面的write函式,這個才是系統呼叫介面。 - 如果偶爾得到來自作業系統的
EAGAIN錯誤訊息,表示可以再次嘗試寫入;這個部份引用到poll函式庫的部份功能,好讓這個重新嘗試的行為可以不那麼立即發生。 - 回傳錯誤或者是已經成功寫入的總字元數。
write 函式收尾
epipecheck(f, e)
if e != nil {
err = f.wrapErr("write", e)
}
return n, err
}
epipecheck 是一個處理與管線以及 EPIPE 錯誤管線訊號有關的函式。根據註解,標準輸出也在可能發生這個錯誤的範圍之中,但是這裡就先不深究。若是之前的 write 的確回傳了非空的 e 錯誤值,那麼
// wrapErr wraps an error that occurred during an operation on an open file.
// It passes io.EOF through unchanged, otherwise converts
// poll.ErrFileClosing to ErrClosed and wraps the error in a PathError.
func (f *File) wrapErr(op string, err error) error {
if err == nil || err == io.EOF {
return err
}
if err == poll.ErrFileClosing {
err = ErrClosed
}
return &PathError{op, f.name, err}
}
將那些錯誤包裝起來,然後回傳。
疑問
runtime.KeepAlive大致上可以顧名思義。但為什麼它出現在讀寫之後?讀寫之前難道就沒有被 runtime 影響的危險嗎?- 處理管線錯誤訊號的時候有瞄到
sigpipe,GO 語言如何處理 signal?
本日小結
- 介紹並使用
git blame的-L搜尋區段功能,對於專案的學習力有幫助 - 作為一個多平台通用語言,以寫入功能作為範例簡單窺探到 GO 的抽象層設計
- 看完了
Fprintln函式,看完可以理解的部份
感謝各位讀者,我們明天再會!
第六天:暫停一下回顧未解問題
- Day: 6
- 發佈日期: 2019-09-21
- 原文: https://ithelp.ithome.com.tw/articles/10218424
前情提要
昨日一層一層瀏覽了不同層次的抽象層,最後把寫入檔案的動作看完,整個 Hello World 程式也暫告結束。
稍事休息
過去幾天我們一步一步的看過 Hello World 主程式所使用的 GO 語言函式庫的內容。任何一個對於程式語言有基礎觀念、並且對於類 Unix 作業系統稍有接觸的的讀者都能夠預期到這樣一個初學者程式應該要有的功能,那就是在呼叫 write() 系統呼叫之前,將傳入的格式化字串整併起來,然後呼叫,至少理論上是如此。
但是顯然,GO 語言給了我們更多驚喜!看看我們前幾日總共累積了多少懸而未決的疑問:
- GO 語言的建構子是以怎樣生成的?
- 所謂的
netpoll系統是指什麼?顯然在創建檔案的時候很重要。 runtime.SetFinalizer是什麼?在整個 GO 語言 runtime 中扮演何種角色?- 追蹤過程中發現搶佔是可以被關掉的,也就是說 GO 語言有非同步的搶佔引擎。其機制為何?
arg.(type)這種功能被稱作 reflect。GO 語言的 reflect 是怎麼做的?internal/race是怎麼樣的函式庫?功能?sync是怎樣的函式庫?功能?runtime.KeepAlive大致上可以顧名思義。但為什麼它出現在讀寫之後?讀寫之前難道就沒有被 runtime 影響的危險嗎?- 處理管線錯誤訊號的時候有瞄到
sigpipe,GO 語言如何處理 signal?
也如筆者第一日預告的那般,這些都是大哉問。今天既然是前一個主題完結的時刻,我們就稍事休息,決定一下下一個主題的走向;至於如何決定,當然就是從這些未釐清的疑點中找尋靈感了。
魅惑的非同步行為
其實從寫下第一行程式碼,到接觸整個資訊世界,rumtime 這個詞就絲毫不教人陌生,畢竟這是許多動態語言都有的所謂執行期環境的特徵,但是具體來說那到底是什麼?始終像是背景設定的一部分一樣,看不見摸不著。這個系列力求深入 GO 語言的實作,正好有機會可以一探究竟。
我們第一個遭遇到的是 runtime.SetFinalizer,這個感覺上與 GO 語言本身的 defer 功能是否有什麼關係呢?如果說真的可以為資源設定一個終結者,那麼那些東西該什麼時候執行?這是否是垃圾回收機制的一部分?又,我們後來也有發現搶佔這個關鍵詞,且還有 KeepAlive 這種保險一般的函式,是否表示 GO 語言在執行的時候其實也有類似 context switch 的機制讓垃圾回收之類的函式搶先做事?如果是的話,這些功能又會如何被整合在一個 GO 語言程式中呢?可是明明是一個不需要多執行緒的程式,為什麼會有多個工作單元可以執行?乍看之下滿滿的都是問號,但是筆者相信有時候問問題比答案更重要,尤其是答案離我們還有一段距離的時候,透過以往從作業系統、程式語言等地方獲得的知識,大概能夠引導問題的方向。
當然,這個部份讓人充滿了求知慾想要一探究竟;但是筆者決定將 runtime、race、sync 等函式庫的探索先擺到後面,因為總有一股直覺,這些東西應該和 goroutine 以及 channal 一起看會更有效果。更何況,現在對筆者來說缺乏一個好的切入點來觀察這些功能,所以先行擱置。
檔案描述子
我們一開始關注 os.Stdout 的時候就已經發現一般檔案與網路串流雖然共用檔案描述子這個介面,但是在抽象層的角度來講很早就已經分家了。所以雖然我們對於這個資料結構的認識仍屬模糊,但只要交叉比對這兩種不同的使用情境,比方說寫入檔案對比傳出封包,應該就可以看到其中的差異,尤其是非阻塞型檔案與輪詢的關係。
這個部份比前段的非同步項目稍微好一點,因為我們很明顯可以多作幾個範例程式就可以開始比對。但是筆者也想先將這個部份擱置下來,因為有另外一項筆者很感興趣的部份。
建構子,程式的初始與執行
是的,其實這就是筆者最感興趣的部份。
所以我們將再度回頭看看這支 Hello World 程式,但不一樣的是這次我們心中不懷抱著對於列印一行字串的期待;反過來的是,我們應該要特別留意那些分明不是在 main 函式裡面的東西。從頭開始,直到結束。
頭是多前面?結束又是多後面?從程式碼到程式當然牽涉到 GO 語言的編譯與連結的實作,但是那些部份完全是獨立的元件,可以日後討論。
所以讓我們重新來看 Hello World 程式吧!
先來一些簡單的數據對照好了。筆者準備了另外一個 C 語言的 Hello World 程式:
#include<stdio.h>
int main(){
printf("Hello World!\n");
return 0;
}
透過靜態編譯之後的檔案與 GO 語言比較的話:C 的這個靜態執行檔是 743K,而 GO 的是 2M。
使用 readelf -a 工具觀察這兩個執行檔,發現 C 的產物定義了 1870 個 symbol,而 GO 的是 3072 個。
使用 objdump -d hw | wc -l 單純觀察程式碼部份的行數,C 是 125997 行,而 GO 是 136940 行。
這就有趣了!仔細回頭看看 readelf 的結果,發現兩者 .text 的量級差不多;拉開最多差距的則是 .rodata,相差超過一個數量級,此外 GO 的執行檔還富含諸多除錯資訊。又,GO 語言獨有的一個區段 .gopclntab,不知道是什麼的 table,竟然接近程式碼區段的二分之一大小,也是相當可觀。
看這些數據做什麼?
其實也沒有特定目的,就是累積一些感覺。其實使用這些工具的重點是要找出 GO 語言的切入點。一個執行檔總是要有進入點的,這樣作業系統檢驗完執行檔與他需要的函式庫之後要轉交 CPU 給它的時候才知道如何轉交。這個進入點資訊可以使用 readelf -h,也就是只看 ELF 檔頭就可以了。
$ readelf -h hw
ELF 檔頭:
魔術位元組:7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
類別: ELF64
資料: 2 的補數,小尾序(little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
類型: EXEC (可執行檔案)
系統架構: Advanced Micro Devices X86-64
版本: 0x1
進入點位址: 0x44f4d0
程式標頭起點: 64 (檔案內之位元組)
區段標頭起點: 456 (檔案內之位元組)
旗標: 0x0
此標頭的大小: 64 (位元組)
程式標頭大小: 56 (位元組)
Number of program headers: 7
區段標頭大小: 64 (位元組)
區段標頭數量: 23
字串表索引區段標頭: 3
得到它了!0x44f4d0!從 objdump -d 去撈撈看這個位址是誰:
000000000044f4d0 <_rt0_amd64_linux>:
44f4d0: e9 2b c9 ff ff jmpq 44be00 <_rt0_amd64>
結果是一個名為 _rt0_amd64_linux 的函式,也沒有作什麼作業系統特別需要的前置操作,就直接跳轉到 _rt0_amd64 去,
000000000044be00 <_rt0_amd64>:
44be00: 48 8b 3c 24 mov (%rsp),%rdi
44be04: 48 8d 74 24 08 lea 0x8(%rsp),%rsi
44be09: e9 02 00 00 00 jmpq 44be10 <runtime.rt0_go>
由於筆者很久沒有摸 x86_64 了,這裡只大概記得 %rdi 和 %rsi 大概是很前面順位的參數用暫存器,因此猜測這兩個儲存到 stack 裡面的參數是 argc 和 argv 應該沒錯。接下來再跳轉到 runtime.rt0_go 去。
筆者搜尋了很久這個 rt0_go 所在的位置,還是沒有頭緒,今日就此打住吧!
疑問
.gopclntab區段是什麼?- 初始化後什麼時候開始進入通用的 GO 語言部份?
本日小結
- 整理並分類目前的疑問
- 重回 hw 程式,但是目標改為尋找整個程式啟動流程
進入點開始當然免不了有一些比較枯燥的平台相依程式碼,但是我們明天也會繼續追蹤!感謝各位讀者陪伴筆者撞牆,無論如何,理解未知的過程總是有趣的。各位讀者,我們明天再會!
第七天:瀏覽系統相依的初始化
- Day: 7
- 發佈日期: 2019-09-22
- 原文: https://ithelp.ithome.com.tw/articles/10219216
前情提要
昨日宣告重啟 Hello World 程式,但是是那些我們所寫的 main 函式以外的部份。目前追蹤到作業系統與架構相依的部份結束,何時會迎來 GO 語言的通用部份?
再次引用 gdb
既然靜態的程式碼追蹤有點困難,那反正我們也都已經知道一個 GO 程式的切入點在哪裡,那麼不是從那個切入點開始慢慢單步執行就可以了嗎?所以筆者這裡打算使用這個方式,還可以順便透過 gdb 解析的除錯訊息不只了解執行的函式,也要知道那些函式在整個 GO 語言專案中的結構與位置。
那麼就來開啟 gdb 吧。
(gdb) b *0x451760
Breakpoint 1 at 0x451760: file /home/alankao/2019Fe/go/src/runtime/rt0_linux_amd64.s, line 8.
(gdb) run
Starting program: /home/alankao/2019Fe/hw
Breakpoint 1, _rt0_amd64_linux () at /home/alankao/2019Fe/go/src/runtime/rt0_linux_amd64.s:8
8 JMP _rt0_amd64(SB)
(gdb) s
_rt0_amd64 () at /home/alankao/2019Fe/go/src/runtime/asm_amd64.s:15
15 MOVQ 0(SP), DI // argc
(gdb)
16 LEAQ 8(SP), SI // argv
(gdb)
17 JMP runtime·rt0_go(SB)
(gdb)
是的,這個部份昨天就已經看過,也就是到達 runtime.rt0_go 函式之前的過程。其中呼叫前的兩個暫存器操作也一如筆者預期的是程式獲得的來自作業系統的訊息:argc 以及 argv。
runtime.rt0_go ...... 對不起,實在是太繁瑣了!
筆者本來想按照順序流水帳的介紹,以達成地毯式的通盤了解,但顯然這樣也是錯誤的抽象層選擇。進入 runtime.rt0_go 之後有一些專屬於 Intel 的檢查過程,實在是繁瑣到筆者直接按緊了 enter 鍵(這在 gdb 的使用情境裡代表重複上一個指令,也就是不斷的下一步),片刻之後才突然印出 Hello World 中止。
所以還是鎖定感興趣的部份好了。回到 src/runtime/asm_amd64.s 之中,可以閱讀片段的註解來理解那些檢查的區段,但是筆者最感興趣的是以下的幾個呼叫(CALL 組語指令),按照順序是
runtime.argsruntime.osinitruntime.schedinit
這三個顯然是初始化函式?繼續看下去的話:
runtime.newproc:這個呼叫之前似乎有取得main函式的起始位址。runtime.mstart:這個開始之後,就啟動了數個 thread,然後印出 Hello World 結束程式了。
之前曾經提過 GO 語言執行檔的 symbol 處理方式是將函式庫名稱與函式名稱以句點連結起來,但是顯然有些如 main.init 這類的就是 GO 在編譯之後生成出來的,從開發者的角度 main 函式庫就只有我們提供的 main 函式而已。那麼這裡的五個呼叫呢?
這裡的五個呼叫,除了 osinit 牽涉到不同的作業系統而有許多函式實體在不同檔案之外,其他的都存在一份於 runtime 函式庫中,也就是整個 GO 專案的 src/runtime 資料夾底下。我們就來看看吧!
runtime.args
這個函式顧名思義是要處理傳入的參數,存在於 src/runtime/runtime1.go 之中:
func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}
argc 與 argv 存在於整個 runtime 函式庫的命名空間底下,所以理論上應該可以使用 runtime.argc 和 runtime.argv 之類的方法來存取;**但是實際上不行!**直接玩玩看下面這個範例的話:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("Hello World!")
fmt.Println(runtime.argv)
}
在編譯過程中就會報錯:
$ go build hw.go
# command-line-arguments
./hw.go:10:14: cannot refer to unexported name runtime.argv
./hw.go:10:14: undefined: runtime.argv
也難怪沒有看過這樣子的用法。
sysargs 又是什麼樣的函式呢?簡單來說就是把作業系統給予的空間好好利用出來的處理過程。作業系統不會只有吝嗇的給參數數量與參數字串陣列,通常程式還會預期自己能夠透過環境變數來判斷所處環境,更進階的用法還有一個擴增向量(Auxilary Vector,通常簡寫為auxv)。
擴增向量通常都用來作些什麼呢?各位讀者不妨試試看這個指令:
LD_SHOW_AUXV=1 ls,可以看到 dynamic linker 印出的訊息喔!
總之,這個函式在 src/runtime/os_linux.go 裡面:
func sysargs(argc int32, argv **byte) {
n := argc + 1
// skip over argv, envp to get to auxv
for argv_index(argv, n) != nil {
n++
}
// skip NULL separator
n++
// now argv+n is auxv
auxv := (*[1 << 28]uintptr)(add(unsafe.Pointer(argv), uintptr(n)*sys.PtrSize))
if sysauxv(auxv[:]) != 0 {
return
}
// In some situations we don't get a loader-provided
// auxv, such as when loaded as a library on Android.
// Fall back to /proc/self/auxv.
...
在筆者引用的區段中,可以見到下半都是針對擴增向量的處理;一開始是用 sysauxv 去設法撈取 loader 給予的內容,而若沒有取得的話,設法從/proc/self/auxv 這個特殊的系統檔案取得。
這些東西長什麼樣子?
一般來說想要偷看記憶體裡面的資訊,只要將之印出來就好。然而,這時候顯然初始化步驟都還沒走完,應該是沒有辦法使用 fmt 函式庫的;若要深究這時候可以使用的其他函式庫,看起來只有
import (
"runtime/internal/sys"
"unsafe"
)
顯然不包含 fmt。前者筆者不確定是什麼東西,已經紀錄在疑問章節之中,後者則是在第一個迴圈呼叫的 argv_index 小函式裡面以及 auxv 變數的生成過程中使用,意指可能不安全的指標存取,也先留待後日研究。
光是引用 "fmt" 函式庫就會造成編譯困難,各位讀者可以試試:修改
src資料夾底下的程式碼之後,執行./make.bash。
理論上這裡可以使用 gdb 去看執行相應區塊時的位址內容,也可使用 print 函數。相對應的使用方法在 os_linux.go 裡面很多,就不細談。
runtime.osinit
這個函式也在 src/runtime/os_linux.go 裡面,內容很單純,
func osinit() {
ncpu = getproccount()
}
也就是說,為 Linux 做的初始化只需要決定有幾顆 CPU 就好。這個 getproccount 函式在同一個檔案內,核心內容是:
...
var buf [maxCPUs / 8]byte
r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0])
if r < 0 {
return 1
}
n := int32(0)
for _, v := range buf[:r] {
for v != 0 {
n += int32(v & 1)
v >>= 1
}
}
...
return n
這個 sched_getaffinity 是 Linux 專有的系統呼叫。第一個參數給定 0 的時候,會將當前可用的 CPU 核心透過遮罩的方式回傳到這裡的 buf 陣列之中。接下來的 for 迴圈也是逐項檢驗內容,並將可用的 CPU 核心數回傳。
疑問
import關鍵字有時會引用多層結構,為什麼要這樣作?- 常常看見
internal什麼什麼。內部的這個關鍵字的差異是什麼?這些函式庫不都是內部的的嗎? unsafe的用途。sched_getaffinity並沒有像之前write那樣最終導到Syscall去。
本日小結
雖然無法如預期那般流水帳地理解進入點,但也是將作業系統初始化之前的部份看完了;接下來的 schedinit 函式將會非常龐大!!!各位讀者,我們明天再會!
第八天:進入 schedinit
- Day: 8
- 發佈日期: 2019-09-23
- 原文: https://ithelp.ithome.com.tw/articles/10219464
前情提要
昨日從一支 GO 語言程式的源頭往下看,在初始化參數(runtime.args)和初始化來自作業系統的資訊(runtime.osinit)方面有個簡單的認識。按照順序的話今天要來看排程初始化(runtime.schedinit)。
runtime.schedinit 在 src/runtime/porc.go 之中
最一開始就有點打啞謎的味道,請看:
// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
_g_ := getg()
if raceenabled {
_g_.racectx, raceprocctx0 = raceinit()
}
註解部份是我們在組語裡面也看見的順序,昨日已經看完了 osinit 的部份。但是這裡就像是小說看到一半突然看見新角色一樣,但是作者又讓他出場得理所當然,也就是這裡的 G 或者 g。這到底是何許人也?先不停在這裡空轉,看看可以閱讀的部份吧!
進到函式裡面的註解,提到說第一個要呼叫的是 raceinit,這個是負責一個叫做 race 偵測器 的機制;顧名思義,大概是說 GO 語言的天生特性導致高度的並行性(concurrency),但整個語言的 runtime 還是想要設法維護程式運行的邏輯不脫軌,因此有這個 race condition 的偵測模組。註解並且強調這個一定要在 mallocinit 呼叫 racemapshadow(抱歉,又是另一個陌生角色)之前做完才行。
有沒有發現一件有趣的事?GO 語言的慣例命名法則是駝峰式,舉個例子大概是
thisIsForExample這樣,但是這裡初始化階段我們看到的是很罕見的一種命名法:不更動大小寫直接串接。若是 Linux 的話也應該至少變成sched_init、race_init之類。
raceinit
雖然 raceenabled 的判定與 raceinit 的呼叫在第一行的 getg 之後,但既然註解已經先提到了,就先來看看它在哪裡好了。事實上,這些 race 功能相關的內容被定義在 src/runtime/race0.go 裡面,而且其實是關閉的!
...
// license that can be found in the LICENSE file.
// +build !race
// Dummy race detection API, used when not built with -race.
在沒有附加 -race 的建置過程的話,這些功能就都不會被用到了。那我們也就暫且跳過。
G?
然後第一行程式碼,披頭就來了一個 _g_ := getg()!那,深入 getg() 的話應該就可以知道 G 是什麼了吧,它定義在 src/runtime/stubs.go裡面,可是...
// getg returns the pointer to the current g.
// The compiler rewrites calls to this function into instructions
// that fetch the g directly (from TLS or from the dedicated register).
func getg() *g
下面沒有了!趕緊看註解:getg 取得當前的 g 並回傳,可是瑞凡,這不是廢話嗎!編譯器會將這個呼叫改寫成直接抓取 g 的指令,比方說從 TLS 或是特定的暫存器裡面。這些人怎麼可以這樣理所當然的 G 來 G 去,卻不告訴我們 G 是什麼呢!?幸好餘光瞄到這個下面的另外一個呼叫 mcall,它的註解很長,但是提到更多新角色比方說是 g0、gsignal,而且還有一個關鍵句:
// mcall switches from the g to the g0 stack and invokes fn(g),
// where g is the goroutine that made the call.
...
TLS 是 Thread Local Storage,執行緒專用的儲存空間
也就是說(請注意這是筆者與各位讀者同步學習時的猜測,很可能在日後證明有錯)這裡的 g 指的應該就是 goroutine 這種** GO 語言原生的執行緒**的其中一個,g0 可能是某個最特定用途的或是 main thread 之類的概念,gsignal 也不難猜想,因為除了序列執行的正常 context 之外本來就會有非同步的 signal context。而這裡的 fn(g) 這種作法,也許就是綁定 g 執行緒的特殊函式?還是說 fn 是指某個函式?不過沒關係這個當作之後的考察目標。
回到 getg
我們可以想像 goroutine 可能也是一個結構體,裡面包含成員變數與函數,所以會有 getg 這種呼叫。因為他真正的內容已經被編譯器代換,並且在 getg 所在的檔案名稱(stubs)可以得知,那只是一個空殼。我們只能去組語檔案挖挖看了。一樣引用 objdump -d 工具會發現,其實根本找不到 getg 這個函式,畢竟註解說的是替換成**存取 TLS 空間或存取專用的暫存器的指令。**不得已,只好看 runtime.schedint 的函式本體:
0000000000429620 <runtime.schedinit>:
429620: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx
429627: ff ff
429629: 48 3b 61 10 cmp 0x10(%rcx),%rsp
42962d: 0f 86 19 02 00 00 jbe 42984c <runtime.schedinit+0x22c>
429633: 48 83 ec 60 sub $0x60,%rsp
429637: 48 89 6c 24 58 mov %rbp,0x58(%rsp)
42963c: 48 8d 6c 24 58 lea 0x58(%rsp),%rbp
429641: 64 48 8b 04 25 f8 ff mov %fs:0xfffffffffffffff8,%rax
429648: ff ff
42964a: 48 89 44 24 38 mov %rax,0x38(%rsp)
42964f: c7 05 37 29 12 00 10 movl $0x2710,0x122937(%rip) # 54bf90 <runtime.sched+0x30>
429656: 27 00 00
...
429843: 00 00
429845: e8 26 d3 ff ff callq 426b70 <runtime.throw>
42984a: 0f 0b ud2
42984c: e8 7f 49 02 00 callq 44e1d0 <runtime.morestack_noctxt>
429851: e9 ca fd ff ff jmpq 429620 <runtime.schedinit>
429856: cc int3
作為對照,原本這個含是的開頭長成這樣:
...
_g_ := getg()
if raceenabled {
_g_.racectx, raceprocctx0 = raceinit()
}
sched.maxmcount = 10000
tracebackinit()
moduledataverify()
...
所以這樣看起來,雖然還不太確定 getg 函式到底被替換成什麼,但是還是可以找到一個參照點,也就是指定 sched.maxmcount 的這個成員變數被指派成 10000,也就是十六進位的 0x2710,所以我們就找到了在 0x42964f 之前,也許都可以說是 getg 函式代換的部份。當然,這麼說並不精確,因為 GO 語言的編譯器很有可能做了很多事情。
事實上如果真的用 objdump -d 瀏覽看看,會發現很多函式都有共通的起頭,那就是 GO 語言的 prologue 形式,大部份都會有像前幾行那樣子的內容。第一行引用的 fs 暫存器正是許多專案用來當作 TLS 的慣例之一。這個指令結束之後取得的東西在 rcx 暫存器中。隨後,rcx 的一個 offset 內容和當前 stack pointer 比較,並包含一個跳轉到後方的 runtime.morestack_noctxt 呼叫,之後再直接轉回 runtime.schedinit,隱含了一個類似遞迴的行為。這個 morestack_noctxt 一樣只有空殼定義在 stubs.go 裡面,本體則是在 src/runtime/asm_amd64.s;不節錄內容,但是這個呼叫常常會在 prologue,也就是在函式開頭,卻發現 stack 空間不夠的時候被呼叫。
更逼近 getg
也就是說,筆者本來想追的是 getg 函式,這看到的卻是類似 prologue-epilogue 對的一般 GO 函式結構而已。於是筆者用了一個比較醜的招式,也就是在 _g_ := getg() 前後附上一個 print 函式夾起來,結果編譯出來是:
429641: e8 8a df ff ff callq 4275d0 <runtime.printlock>
429646: 48 8d 05 47 ad 08 00 lea 0x8ad47(%rip),%rax # 4b4394 <go.string.*+0x34>
42964d: 48 89 04 24 mov %rax,(%rsp)
429651: 48 c7 44 24 08 02 00 movq $0x2,0x8(%rsp)
429658: 00 00
42965a: e8 91 e8 ff ff callq 427ef0 <runtime.printstring>
42965f: e8 ec df ff ff callq 427650 <runtime.printunlock>
429664: 64 48 8b 04 25 f8 ff mov %fs:0xfffffffffffffff8,%rax
42966b: ff ff
42966d: 48 89 44 24 38 mov %rax,0x38(%rsp)
429672: e8 59 df ff ff callq 4275d0 <runtime.printlock>
429677: 48 8d 05 16 ad 08 00 lea 0x8ad16(%rip),%rax # 4b4394 <go.string.*+0x34>
中間的兩個空行是筆者安插的以求明顯閱讀。第一個空行以前是比對之後發現的 print 函式的真身,由 printlock 起頭,printunlock 結束,而且會要去某個編譯期決定的記憶體位置撈取所需印出的字串;之後還放治了兩個變數到 stack 裡面,根據格式看來應該是字串起始指標與該字串長度。
也就是說,getg 函式,也就是呼叫者企圖取得自己所屬的 goroutine 的這個呼叫,在 x86_64 架構裡面是一個暫存器的存取,並將之放置到函式視野的空間裡面。
疑問
- GO 命名的歷史淵源,還有為什麼 runtime 跟大家都不一樣?是否是 linker 之類的工具鏈限制使然?
- goroutine 的構成,顯然是理解 GO 語言的關鍵。
g0和gsignal分別是怎麼來的?如何生成或指派的? - fn 函式?
- 怎麼開啟具備 race 功能的編譯模式?
本日小結
觀看 schedinit 之路一波三折,但是也看到許多有趣的 GO 語言結構;由於 runtime 的真實樣貌有許多透過編譯器解決,因此也有比較多組語的參照。各位讀者,我們明天再會!
第九天:進入 schedinit (之二)
- Day: 9
- 發佈日期: 2019-09-24
- 原文: https://ithelp.ithome.com.tw/articles/10220258
前情提要
昨日剛開始追蹤排程初始化(runtime.schedinit)函式的開頭部份,註解真的幫了大忙;大略上瀏覽過一些觀念,比方說 goroutine 的存在。
接續的 runtime.schedinit (在 src/runtime/porc.go 之中)
...
sched.maxmcount = 10000
tracebackinit()
moduledataverify()
stackinit()
mallocinit()
mcommoninit(_g_.m)
...
先截個七行,因為筆者也沒有把握今天可以追蹤多遠,總之先列出這五個接下來的呼叫吧!之所以選到這裡,是因為昨日我們很努力觀察 getg 函式,而它所回傳的 _g_ 第一次派上場的地方,就是這裡最後一行的 mcommoninit,這個該怎麼顧名思義呢?Memory common init 的意思嗎?沒關係,讓我們繼續看下去。
tracebackinit
筆者慢慢開始覺得亂猜很有趣,所以這裡也直接猜猜看。筆者猜想 trace back 應該類似 kernel 出問題時的 dump stack 機制,或者 gdb 裡面也有一個 backtrace 指令可以將目前為止的 call stack 展示出來看。 GO 語言也和 Python 一樣有在出錯時自動回溯 call stack 的機制,比方說如果我們使用這個程式碼片段:
a := make([]int, 1)
a[1] = 1
到時就會有錯誤輸出類似:
panic: runtime error: index out of range
goroutine 1 [running]:
main.main()
/home/noner/FOSS/2019ITMAN/go_internal/hw.go:8 +0x11
讀者可以參考下面引用的程式碼片段的上面有一大段註解,明確指出這份 GO 源碼檔案就是在實作通用的 stack trace 機制。同時還有一些關於回傳位址屬於 stack 存放或暫存器存放的分類細節,這裡就暫不深入。
不管怎麼樣,這個函式在 src/runtime/traceback.go 裡面:
...
var skipPC uintptr
func tracebackinit() {
// Go variable initialization happens late during runtime startup.
// Instead of initializing the variables above in the declarations,
// schedinit calls this function so that the variables are
// initialized and available earlier in the startup sequence.
skipPC = funcPC(skipPleaseUseCallersFrames)
}
...
這個註解解釋的是另外一件事情:GO 語言的變數初始化在 runtime 初始化的晚期(下暫略)。可是這又甘 traceback 的功能什麼事呢?所以後面三行才道出原因。如果放在函式外初始化,就可能會沒有辦法在需要的時候盡早開始使用 skipPC 這個變數,所以乾脆直接讓 schedinit 早一點呼叫這個 tracebackinit 函式。那麼,這個希望可以被越早使用越好的 skipPC 是什麼東西呢?
筆者稍微搜查一下發現這個變數只在 src/runtime/traceback.go 以及 src/runtime/symtab.go 裡面有使用到。但相關的邏輯對於這時候我們所掌握的資訊而言實在太少了,應該先行跳過。我們之後可以使用比較大一點的函式,若有比較深的 call stack,應該可以更方便觀察相關的行為。至於賦值的 funcPC 函式是一個使用到 unsafe 指標存取的函式,用來取得傳入的函式的進入點,大致上類似在 C 裡面直接對函數取指標;其傳入參數為 skipPleaseUseCallersFrames,在 x86_64 的實驗平台上反組譯後發現裡面都是 nop,詳情待解。
moduledataverify (在 src/runtime/symtab.go 之中)
func moduledataverify() {
for datap := &firstmoduledata; datap != nil; datap = datap.next {
moduledataverify1(datap)
}
}
這是一個從 firstmoduledata 這個物件開始瀏覽到最後的一個迴圈。這個物件的定義是:
var firstmoduledata moduledata // linker symbol
而這個 moduledata 具備良好的說明:
// moduledata records information about the layout of the executable
// image. It is written by the linker. Any changes here must be
// matched changes to the code in cmd/internal/ld/symtab.go:symtab.
// moduledata is stored in statically allocated non-pointer memory;
// none of the pointers here are visible to the garbage collector.
type moduledata struct {
pclntable []byte
...
從這個部份的功能在 symtab.go 這件事情看來,原來 moduledata 這個結構是 linker 用來紀錄整個執行檔內部的排列方式的東西,而且這個部份的記憶體是垃圾回收機制無法插手的靜態區域。定睛一看,其實 pclntable 這個成員變數陣列有點眼熟對吧?因為在第六日,我們就發現了執行檔裡面有個 .gopclntab 區段,看來就是 linker 生成 ELF 時的實際操作了。
至於迴圈內的 moduledataverify1 函式呼叫,邏輯還是很複雜,應該是要驗證些 symbol 與位置之間的關係的樣子,因為有一些錯誤訊息像是不合法的 symbol table、未依照 PC 位址排序的函式 symbol table。其實在這附近有說明 function table 的資料結構的設計理念,但是這就等到之後再來探究吧。
stackinit(在 src/runtime/stack.go 中)
func stackinit() {
if _StackCacheSize&_PageMask != 0 {
throw("cache size must be a multiple of page size")
}
for i := range stackpool {
stackpool[i].init()
}
for i := range stackLarge.free {
stackLarge.free[i].init()
}
}
throw 顯然是一種印出錯誤訊息且不回傳的那種程式結束點,順便兼當註解用,非常清楚。這個資格審核通過之後,就是針對 stackpool 以及 stackLarge.free 這兩個變數的初始化。這兩個變數其實都是同一個型別,參看他們的定義:
// Global pool of spans that have free stacks.
// Stacks are assigned an order according to size.
// order = log_2(size/FixedStack)
// There is a free list for each order.
// TODO: one lock per order?
var stackpool [_NumStackOrders]mSpanList
var stackpoolmu mutex
// Global pool of large stack spans.
var stackLarge struct {
lock mutex
free [heapAddrBits - pageShift]mSpanList // free lists by log_2(s.npages)
}
還蠻興奮這裡看到一個 TODO,因為也許之後有空可以來學著送送看 patch。總之這兩個變數都是 mSpanList 的陣列型別,它的 init 方法在 src/runtime/mheap.go 裡面:
// Initialize an empty doubly-linked list.
func (list *mSpanList) init() {
list.first = nil
list.last = nil
}
總之,初始化就是這麼回事吧。但是 heap 專指動態配置的那些記憶體,這部份真正的管理方法,就也留到有空的時候再探討吧。
疑問
skipPC的具體用途?- GO 語言抽象了所有不同架構,仍然保持
PC、SP、FP、LR等關鍵抽象暫存器,這些對於整個 stack trace 功能的具體實作為何? - goroutine 的構成,顯然是理解 GO 語言的關鍵。
g0和gsignal分別是怎麼來的?如何生成或指派的? - moduledata 有沒有別的意思?就是 symbol table 而已嗎?
- 在
heap.go裡面看到很多 heap 的管理都有強調不能使用 heap 來管理 heap,這如何作到?
本日小結
繼續往後看 schedinit ,多走了三個初始化的部份,也都先在筆者認為適合的部份打住。明天再繼續看下去!各位讀者,我們明天再會!
第十天:初遇 GO 語言密碼:G、M、P?
- Day: 10
- 發佈日期: 2019-09-25
- 原文: https://ithelp.ithome.com.tw/articles/10220699
前情提要
昨日追蹤排程初始化(runtime.schedinit)函式內容,多閱讀了 tracebackinit 函式(與追溯 stack 機制有關的初始化,其實就是將一個 skipPC 變數初始化)、moduledaraverify 函式(函式 symbol、檔案、記憶體位址之間的啟動時檢查)還有 stackinit 函式。
接續的 runtime.schedinit (在 src/runtime/porc.go 之中)
func schedinit() {
...
mallocinit()
mcommoninit(_g_.m)
cpuinit() // must run before alginit
...
mallocinit (src/runtime/malloc.go)
檔案一開始就長達一百多行的註解在解釋整個 GO 語言的記憶體分配器是怎麼運作的。除此之外,還有關於 GO 語言的 Virtual Menory 配置的解說。這個部份由於之後顯然有機會重新造訪,所以大部分的細節也先跳過,只稍微看看這個函式的核心內容:
func mallocinit() {
// 錯誤檢查
...
// Initialize the heap.
mheap_.init()
_g_ := getg()
_g_.m.mcache = allocmcache()
// 依作業系統不同而做的額外 hint 機制
...
mheap_ 是 mheap 結構的一個成員,它掌管 malloc 所需要使用到的 heap 空間,以 8 KB 為單位;mcache 也是一種結構,用來代表每個核心使用的記憶體。
mcommoninit (一樣在 src/runtime/proc.go)
一開始就讓人覺得很蹊蹺,
func mcommoninit(mp *m) {
_g_ := getg()
// g0 stack won't make sense for user (and is not necessary unwindable).
if _g_ != _g_.m.g0 {
callers(1, mp.createstack[:])
}
...
稍微回顧一下,這個 mcommoninit 函式本身就是第一個使用到 _g_,也就是當前 goroutine 指標當作參數的的函式(而那個 _g_ 是最一開始在 schedinit 函式裡面被呼叫的),但是一進來之後就又立刻呼叫了 getg 函式,是否表示 mcommoninit 函式除了在一般程序開始時使用,也在其他時候使用呢?稍微 grep 一下,果然在一個名叫 allocm 的函式也有使用到 mcommoninit。
至於參數型別的 m(這種極簡型別命名法如果來多了會非常困擾,幸好目前為止只有 g 和 m),可以從 src/runtime/runtime2.go 裡面找到端倪。g 具有一個型別為 m 的成員,解釋上面只有說是當前的 m,資訊並不多;但是 m 具有三個型別為 g 的成員,分別是 g0(負責 scheduling 的那個 goroutine)、gsignal(負責信號處理) 以及 curg(正在運行的這個)。
回到這裡節錄的程式碼。如果此時取得的 _g_ 不同於 g0 的話,就要呼叫 caller 函式。這個 caller 函式結果會呼叫到我們之前曾在探討 tracebackinit 時遇到的一個大型函式,主要用意應該就是要在錯誤的時候回報呼叫過程吧。
標記編號
...
lock(&sched.lock)
if sched.mnext+1 < sched.mnext {
throw("runtime: thread ID overflow")
}
mp.id = sched.mnext
sched.mnext++
...
這一段的主角仍然是是傳進來的 mp:這個函式的 m 型別結構參數。一開使用 sched.lock 將整個區段鎖成 critical section,因為裡面的內容會修改到 sched 本體的緣故; sched 變數本身是 schedt 型別(定義在 runtime/runtime2.go 裡面)的一個變數。這裡顯示的是 mnext 這個既可以當作目前為止創建的 m 的數量也可以當作下一個 m 的 ID 的量,如何被使用及維持一致性。
mp.fastrand[0] = 1597334677 * uint32(mp.id)
mp.fastrand[1] = uint32(cputicks())
if mp.fastrand[0]|mp.fastrand[1] == 0 {
mp.fastrand[1] = 1
}
mpreinit(mp)
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
前半段與亂數較有關係,但是機制上筆者完全無從猜測起,因只加到疑問中。
mpreinit 函式則是在 runtime/os_linux.go 中,
// Called to initialize a new m (including the bootstrap m).
// Called on the parent thread (main thread in case of bootstrap), can allocate memory.
func mpreinit(mp *m) {
mp.gsignal = malg(32 * 1024) // Linux wants >= 2K
mp.gsignal.m = mp
}
這個呼叫透過 malg 函式配置了一個新的 gorotine 作為 mp 的 gsignal 成員。
垃圾蒐集機制初登場
// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
mp.alllink = allm
這一段只能算是先由註解的說明獲得一些線索,顯然 GO 的垃圾收集機制有可能會將當前的 m 整個回收掉,所以這裡將 allm 變數賦值予它。
之後的內容與 cgo、作業系統相依的部份有關,因此現在就先加到疑問章節中,留待日後探索。
cpuinit 函式(同樣在 runtime/proc.go)
沒什麼有趣的,主要是各種不同架構的 CPU 本身就會有各種擴充搭配。比方說同樣是 intel 的 CPU,有些強一點的伺服器 CPU 可能同時具有向量運算指令集和虛擬化指令集,但是文書筆電用的 i3 可能就沒有。
但是在爬梳部份程式碼時發現了 runtime/proc.go 裡面,由開發者給的最大的禮物:鳥瞰式的架構註解!在開頭的部份:
// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
//
// Design doc at https://golang.org/s/go11sched.
所以之前都誤會了 m 以為是記憶體的意思,結果原來是抽象意義的機器,表現在可以動態排程的 goroutine 上面的話,也就是 worker thread 的意思了。p 這個抽象結構我們之前尚未遇過,但總之就是處理器本身的意思。
疑問
- 記憶體初始化的細節?
- 亂數為何需要在程序啟動的早期設置?
fastrand的用意為何? 為何選定特殊的魔術數字1597334677? - stackguard 顧名思義是 stack 的保護機制,GO 如何實作這個功能?
- GC 如何影響
m與g的運作? g和p之間的關係?
本日小結
追蹤 schedinit 的過程跌跌撞撞,疑問越積越多......但學習本來就是如此!累積夠多常識之後就能夠轉換成知識了吧!無論如何,我們明日再會!
第十一天:繼續奮戰 schedinit
- Day: 11
- 發佈日期: 2019-09-26
- 原文: https://ithelp.ithome.com.tw/articles/10221164
前情提要
昨日也追蹤了 schedinit 函式的幾個初始化部份。
schedinit ... 今天看完就可以過半了吧
...
alginit() // maps must not be used before this call
modulesinit() // provides activeModules
typelinksinit() // uses maps, activeModules
itabsinit() // uses activeModules
msigsave(_g_.m)
initSigmask = _g_.m.sigmask
...
alginit 函式(在 runtime/alg.go)
這個函式有一些架構相依的判斷,用以決定是否呼叫後續的一個初始化函式 initAlgAES。筆者無意深究 AES 的內部實作,但是 GO 語言在這裡似乎是將 AES 本身當作是一種 hash 的手段。
modulesinit 函式(在 runtime/symtab.go)
這邊筆者先翻譯一下註解的部份:
moduleinit 函式從所有的 module 當中創造出一個代表 active module 的 slice。
一個 module 第一次被動態連結器(dynamic linker)載入時,一個名為 .init_array 的函式會被喚醒並呼叫 addmoduledata,將當前的 module 加入以 firstmoduledata 為首的 linked list 之中。 ...
也就是說這裡的 module 其實接近 C 裡面的那種 object 的意思吧。且看程式碼:
func modulesinit() {
modules := new([]*moduledata)
for md := &firstmoduledata; md != nil; md = md.next {
if md.bad {
continue
}
*modules = append(*modules, md)
if md.gcdatamask == (bitvector{}) {
md.gcdatamask = progToPointerMask((*byte)(unsafe.Pointer(md.gcdata)), md.edata-md.data)
md.gcbssmask = progToPointerMask((*byte)(unsafe.Pointer(md.gcbss)), md.ebss-md.bss)
}
}
...
果然大致上就是從 firstmoduledata 開始逐一掃過每一個 module,然後加入到 modules 這個變數之中。後續的 gc*mask 操作,就先留在疑問裡面了,筆者猜想這應該是要讓 GC 避開 data 和 bss 區段的意義。
for i, md := range *modules {
if md.hasmain != 0 {
(*modules)[0] = md
(*modules)[i] = &firstmoduledata
break
}
}
atomicstorep(unsafe.Pointer(&modulesSlice), unsafe.Pointer(modules))
這個是因為 modules 這個陣列的順序有意義,因此要把具有 main symbol 的 module 提到最前面。最後一行則是將目前為止設置的 modules 這個 slice 儲存到 modulesSlice 去並全域化,若要取得這整個 slice 只需要呼叫 activeModules 函式,
func activeModules() []*moduledata {
p := (*[]*moduledata)(atomic.Loadp(unsafe.Pointer(&modulesSlice)))
if p == nil {
return nil
}
return *p
}
typelinksinit 函式(在 runtime/type.go)
開頭註解的說明指出這個函式要掃過所有 module 使用到的型別,筆者猜想這應該是自定義型別的意思吧?
func typelinksinit() {
if firstmoduledata.next == nil {
return
}
typehash := make(map[uint32][]*_type, len(firstmoduledata.typelinks))
modules := activeModules()
...
我們的範例程式在最一開始的這個判斷就已經回傳了,所以後續我們也就不深究了,畢竟型別系統並不在一開始預定要追蹤的主題中,而且我們的進度已經快要來不及啦!
itabinit 函式(在 runtime/iface.go)
func itabsinit() {
lock(&itabLock)
for _, md := range activeModules() {
for _, i := range md.itablinks {
itabAdd(i)
}
}
unlock(&itabLock)
}
每一個 md,也就是 module,都有一個名為 itablinks 的成員,這個成員的型別是 []*itab,也就是 itab 指標的陣列。itab 是一種存放 interface 用的型別,必須被配置在不會被 GC 回收的記憶體中。
msigsave 函式(同樣在 runtime/proc.go)
由於 GO 語言支援其他程式語言的接口,因此其他語言的程式也可以引用 GO 的程式。然而,GO 語言有許多隱藏設定,比方說我們一直看到的 G、M 之類的概念,這些都必須有意識地保存下來才行。關於類 Unix 系統的 signal 機制,也是如此,這個函式就是在保存其他執行期環境呼叫到 GO 程式之時用來儲存原本的 signal mask。
// msigsave saves the current thread's signal mask into mp.sigmask.
// This is used to preserve the non-Go signal mask when a non-Go
// thread calls a Go function.
// This is nosplit and nowritebarrierrec because it is called by needm
// which may be called on a non-Go thread with no g available.
//go:nosplit
//go:nowritebarrierrec
func msigsave(mp *m) {
sigprocmask(_SIG_SETMASK, nil, &mp.sigmask)
}
這個語法也與 sigprocmask 系統呼叫相同,有興趣的讀者可以試試看 man sigprocmask:
NAME
sigprocmask, rt_sigprocmask - examine and change blocked signals
SYNOPSIS
#include <signal.h>
/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
...
If set is NULL, then the signal mask is unchanged (i.e., how is ignored), but the current value of the signal mask is never‐
theless returned in oldset (if it is not NULL).
...
也就是說,當前的 mask 會被存到 mp.sigmask 去,而在回到 schedinit 之後,
initSigmask = _g_.m.sigmask
就將之設為初始化的 signal mask。
疑問
- 為什麼 maps 要在
alginit之後才能用? - atomic 系列函式是如何實作的?
- 常常看到註解內有
//go:nosplit這種實際上類似給編譯器的 hint,運作機制是?
本日小結
明天就要迎來追蹤 schedinit 的最後一天啦!真正的目標是 GO 程式的初始化,但是我們就一頭栽入了 runtime 的初始化之中。各位讀者,我們明日再會!
第十二天:簡單除錯 GO 語言程式
- Day: 12
- 發佈日期: 2019-09-27
- 原文: https://ithelp.ithome.com.tw/articles/10221613
前情提要
schedinit 函式終於接近尾聲。昨日追蹤的是與 module、signal 相關的幾項初始化。
schedinit
...
goargs()
goenvs()
parsedebugvars()
...
其餘的部份比較瑣碎,因此 schedinit 函式就看到這裡為止吧。
goargs 函式(在 runtime/runtime1.go)
不得不說筆者對於
rintime1、runtime2這種命名實在極不欣賞,但顯然不可能有十全十美的設計,也許這麼命名也是有緣由的?之後再細究。
func goargs() {
if GOOS == "windows" {
return
}
argslice = make([]string, argc)
for i := int32(0); i < argc; i++ {
argslice[i] = gostringnocopy(argv_index(argv, i))
}
}
windows 系列不需要處理這個部份?為什麼?只是我們使用的 Linux 環境顯然不會進入這個路徑,所以就不理會了。處理命令列參數的 argslice 變數先透過 make 呼叫作成,然後引用 argv_index 這個我們之前也看過的函式來取得每一個參數字串的位址。至於如何不拷貝就建構出這個參數字串的 slice,就要看看 gostringnocopy 這個函式怎麼做了:(在 runtime/string.go)
func gostringnocopy(str *byte) string {
ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
s := *(*string)(unsafe.Pointer(&ss))
return s
}
先是使用傳入的 byte 陣列建構 ss、型別為 stringStruct 的變數,然後之後再用 unsafe 的手法取得指標,賦予成真正的 string 變數回傳。
goenvs 函式(runtime/os_linux.go)
goenvs 函式直接轉手呼叫了下面這個位在 runtime/runtime1.go 的函式:
func goenvs_unix() {
// TODO(austin): ppc64 in dynamic linking mode doesn't
// guarantee env[] will immediately follow argv. Might cause
// problems.
n := int32(0)
for argv_index(argv, argc+1+n) != nil {
n++
}
envs = make([]string, n)
for i := int32(0); i < n; i++ {
envs[i] = gostring(argv_index(argv, argc+1+i))
}
}
這在很之前就曾經看過類似的初始化了!初始化環境變數時,使用到的一個重要假設就是這裡最一開始的 TODO 所提到的環境變數應該要直接跟在命令列參數後面。這裡的邏輯與上述類似,但為什麼不用 gostringnocopy 的版本?
parsedebugvar 函式(runtime/runtime1.go)
這個函式解析 GODEBUG 和 GOTRACEBACK 環境變數,並依照指定的參數執行不同的除錯行為。首先第一個檢查的是 GODEBUG,透過一個指定初始值與中止條件、但並未指定迴圈前進條件的 for 迴圈:
func parsedebugvars() {
...
for p := gogetenv("GODEBUG"); p != ""; {
field := ""
i := index(p, ",")
if i < 0 {
field, p = p, ""
} else {
field, p = p[:i], p[i+1:]
}
i = index(field, "=")
if i < 0 {
continue
}
key, value := field[:i], field[i+1:]
這部份就是純粹的字串處理了。p 就是環境變數 export GODEBUG=... 的等號之後的一整段字串。由這個處理方式可知,GODEBUG 可以非常多功能的處理多組以逗號分隔、以等號賦值的 key-value 設定。至於哪些設定可以被接納呢?不妨參考這份官方文件的 Environment Variables 一節。
筆者這裡挑選其中最簡單易懂的兩個選項出來玩玩看:allocfreetrace、schedtrace;前者是在每一次的記憶體配置與釋放時印出訊息,後者則是每隔一段時間印出 scheduler 的即時動態。先以以 hello world 程式為例看看前者的效應:
$ GODEBUG=allocfreetrace=1 ./hw
...
tracealloc(0xc0000941a0, 0xd0, map.bucket[string]*unicode.RangeTable)
goroutine 1 [running, locked to thread]:
runtime.mallocgc(0xd0, 0x4b0700, 0x1, 0xc000015438)
/home/noner/FOSS/2019ITMAN/go/src/runtime/malloc.go:1094 +0x4da fp=0xc00008ed48 sp=0xc00008eca8 pc=0x40b0ea
runtime.newobject(...)
/home/noner/FOSS/2019ITMAN/go/src/runtime/malloc.go:1151
runtime.(*hmap).newoverflow(0xc000092030, 0x4a4b00, 0xc000015320, 0xc0000940d0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/map.go:262 +0x2b5 fp=0xc00008eda8 sp=0xc00008ed48 pc=0x40c315
runtime.mapassign_faststr(0x4a4b00, 0xc000092030, 0x4c1736, 0x9, 0xc0000154b0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/map_faststr.go:278 +0x220 fp=0xc00008ee10 sp=0xc00008eda8 pc=0x40fff0
unicode.init()
/home/noner/FOSS/2019ITMAN/go/src/unicode/tables.go:3522 +0x12c7 fp=0xc00008ee70 sp=0xc00008ee10 pc=0x467c97
runtime.doInit(0x549e20)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5222 +0x8a fp=0xc00008eea0 sp=0xc00008ee70 pc=0x436f2a
runtime.doInit(0x54aec0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008eed0 sp=0xc00008eea0 pc=0x436ef7
runtime.doInit(0x54a460)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008ef00 sp=0xc00008eed0 pc=0x436ef7
runtime.doInit(0x54b7c0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008ef30 sp=0xc00008ef00 pc=0x436ef7
runtime.doInit(0x549da0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008ef60 sp=0xc00008ef30 pc=0x436ef7
runtime.main()
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:190 +0x1da fp=0xc00008efe0 sp=0xc00008ef60 pc=0x42aefa
runtime.goexit()
/home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:1357 +0x1 fp=0xc00008efe8 sp=0xc00008efe0 pc=0x453331
...
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
113
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
108
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
111
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
107
中間略過很多內容,因為真正的印出訊息量相當龐大。事實上,在筆者的電腦上,直接運行 hellow world 程式的時間約是 2 ms,然而搭配這個印出記憶體紀錄的 debug 選項之後,約是 50 ms 之久。後續的這幾個指令示意,實際上的配置次數與執行時狀態有關,但這樣的 stack trace 也大概有 110 行左右。
如果照著這 100 行左右的 bt 看,不就能夠掌握整個 hw 範例的生成過程嗎?也是另外一個切入的角度...
有趣的是,在開啟這個選項下執行的 hw 範例雖然有很多
tracealloc,但卻一個tracefree都沒有,應該是因為程式本身很小的緣故?如果是針對 docker 執行檔無參數直接執行,則會看到許多tracefree。再進一步簡單測試發現,docker 指令直接執行大概會經過 45K 道tracealloc,但只會經過 21K+ 道tracefree。
第二個選項 schedtrace 用在 hw 上面的話沒有什麼意思,因為都只有一行就結束了。為此,我們需要豪華一點的範例,這裡因為篇幅的因素,就留到下一篇吧!
疑問
- 為什麼 windows 不用相關機制來處理參數?windows 還是可以有命令列程式吧?
gostringnocopy函式裡面有一些魔幻的手法在轉換結構體與string型別變數,後面的指標機制怎麼實作?- 為什麼環境變數的陣列建構時不用
gostringnocopy? - 兩個選項一起設置的話,印出的部份會互相干擾,這難道不是 bug 嗎?
本日小結
今日我們瀏覽了 goargs 與 goenvs 兩個函式是如何處理初始過程的重要資訊,也花了較多篇幅介紹可以輕鬆透過環境變數啟動的除錯模式選項之一。各位讀者,我們明日再會!### 前情提要
schedinit 函式終於接近尾聲。昨日追蹤的是與 module、signal 相關的幾項初始化。
schedinit
...
goargs()
goenvs()
parsedebugvars()
...
其餘的部份比較瑣碎,因此 schedinit 函式就看到這裡為止吧。
goargs 函式(在 runtime/runtime1.go)
不得不說筆者對於
rintime1、runtime2這種命名實在極不欣賞,但顯然不可能有十全十美的設計,也許這麼命名也是有緣由的?之後再細究。
func goargs() {
if GOOS == "windows" {
return
}
argslice = make([]string, argc)
for i := int32(0); i < argc; i++ {
argslice[i] = gostringnocopy(argv_index(argv, i))
}
}
windows 系列不需要處理這個部份?為什麼?只是我們使用的 Linux 環境顯然不會進入這個路徑,所以就不理會了。處理命令列參數的 argslice 變數先透過 make 呼叫作成,然後引用 argv_index 這個我們之前也看過的函式來取得每一個參數字串的位址。至於如何不拷貝就建構出這個參數字串的 slice,就要看看 gostringnocopy 這個函式怎麼做了:(在 runtime/string.go)
func gostringnocopy(str *byte) string {
ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
s := *(*string)(unsafe.Pointer(&ss))
return s
}
先是使用傳入的 byte 陣列建構 ss、型別為 stringStruct 的變數,然後之後再用 unsafe 的手法取得指標,賦予成真正的 string 變數回傳。
goenvs 函式(runtime/os_linux.go)
goenvs 函式直接轉手呼叫了下面這個位在 runtime/runtime1.go 的函式:
func goenvs_unix() {
// TODO(austin): ppc64 in dynamic linking mode doesn't
// guarantee env[] will immediately follow argv. Might cause
// problems.
n := int32(0)
for argv_index(argv, argc+1+n) != nil {
n++
}
envs = make([]string, n)
for i := int32(0); i < n; i++ {
envs[i] = gostring(argv_index(argv, argc+1+i))
}
}
這在很之前就曾經看過類似的初始化了!初始化環境變數時,使用到的一個重要假設就是這裡最一開始的 TODO 所提到的環境變數應該要直接跟在命令列參數後面。這裡的邏輯與上述類似,但為什麼不用 gostringnocopy 的版本?
parsedebugvar 函式(runtime/runtime1.go)
這個函式解析 GODEBUG 和 GOTRACEBACK 環境變數,並依照指定的參數執行不同的除錯行為。首先第一個檢查的是 GODEBUG,透過一個指定初始值與中止條件、但並未指定迴圈前進條件的 for 迴圈:
func parsedebugvars() {
...
for p := gogetenv("GODEBUG"); p != ""; {
field := ""
i := index(p, ",")
if i < 0 {
field, p = p, ""
} else {
field, p = p[:i], p[i+1:]
}
i = index(field, "=")
if i < 0 {
continue
}
key, value := field[:i], field[i+1:]
這部份就是純粹的字串處理了。p 就是環境變數 export GODEBUG=... 的等號之後的一整段字串。由這個處理方式可知,GODEBUG 可以非常多功能的處理多組以逗號分隔、以等號賦值的 key-value 設定。至於哪些設定可以被接納呢?不妨參考這份官方文件的 Environment Variables 一節。
筆者這裡挑選其中最簡單易懂的兩個選項出來玩玩看:allocfreetrace、schedtrace;前者是在每一次的記憶體配置與釋放時印出訊息,後者則是每隔一段時間印出 scheduler 的即時動態。先以以 hello world 程式為例看看前者的效應:
$ GODEBUG=allocfreetrace=1 ./hw
...
tracealloc(0xc0000941a0, 0xd0, map.bucket[string]*unicode.RangeTable)
goroutine 1 [running, locked to thread]:
runtime.mallocgc(0xd0, 0x4b0700, 0x1, 0xc000015438)
/home/noner/FOSS/2019ITMAN/go/src/runtime/malloc.go:1094 +0x4da fp=0xc00008ed48 sp=0xc00008eca8 pc=0x40b0ea
runtime.newobject(...)
/home/noner/FOSS/2019ITMAN/go/src/runtime/malloc.go:1151
runtime.(*hmap).newoverflow(0xc000092030, 0x4a4b00, 0xc000015320, 0xc0000940d0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/map.go:262 +0x2b5 fp=0xc00008eda8 sp=0xc00008ed48 pc=0x40c315
runtime.mapassign_faststr(0x4a4b00, 0xc000092030, 0x4c1736, 0x9, 0xc0000154b0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/map_faststr.go:278 +0x220 fp=0xc00008ee10 sp=0xc00008eda8 pc=0x40fff0
unicode.init()
/home/noner/FOSS/2019ITMAN/go/src/unicode/tables.go:3522 +0x12c7 fp=0xc00008ee70 sp=0xc00008ee10 pc=0x467c97
runtime.doInit(0x549e20)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5222 +0x8a fp=0xc00008eea0 sp=0xc00008ee70 pc=0x436f2a
runtime.doInit(0x54aec0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008eed0 sp=0xc00008eea0 pc=0x436ef7
runtime.doInit(0x54a460)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008ef00 sp=0xc00008eed0 pc=0x436ef7
runtime.doInit(0x54b7c0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008ef30 sp=0xc00008ef00 pc=0x436ef7
runtime.doInit(0x549da0)
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:5217 +0x57 fp=0xc00008ef60 sp=0xc00008ef30 pc=0x436ef7
runtime.main()
/home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:190 +0x1da fp=0xc00008efe0 sp=0xc00008ef60 pc=0x42aefa
runtime.goexit()
/home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:1357 +0x1 fp=0xc00008efe8 sp=0xc00008efe0 pc=0x453331
...
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
113
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
108
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
111
$ GODEBUG=allocfreetrace=1 ./hw 2>&1 | grep tracealloc | wc -l
107
中間略過很多內容,因為真正的印出訊息量相當龐大。事實上,在筆者的電腦上,直接運行 hellow world 程式的時間約是 2 ms,然而搭配這個印出記憶體紀錄的 debug 選項之後,約是 50 ms 之久。後續的這幾個指令示意,實際上的配置次數與執行時狀態有關,但這樣的 stack trace 也大概有 110 行左右。
如果照著這 100 行左右的 bt 看,不就能夠掌握整個 hw 範例的生成過程嗎?也是另外一個切入的角度...
有趣的是,在開啟這個選項下執行的 hw 範例雖然有很多
tracealloc,但卻一個tracefree都沒有,應該是因為程式本身很小的緣故?如果是針對 docker 執行檔無參數直接執行,則會看到許多tracefree。再進一步簡單測試發現,docker 指令直接執行大概會經過 45K 道tracealloc,但只會經過 21K+ 道tracefree。
第二個選項 schedtrace 用在 hw 上面的話沒有什麼意思,因為都只有一行就結束了。為此,我們需要豪華一點的範例,這裡因為篇幅的因素,就留到下一篇吧!
疑問
- 為什麼 windows 不用相關機制來處理參數?windows 還是可以有命令列程式吧?
gostringnocopy函式裡面有一些魔幻的手法在轉換結構體與string型別變數,後面的指標機制怎麼實作?- 為什麼環境變數的陣列建構時不用
gostringnocopy? - 兩個選項一起設置的話,印出的部份會互相干擾,這難道不是 bug 嗎?
本日小結
今日我們瀏覽了 goargs 與 goenvs 兩個函式是如何處理初始過程的重要資訊,也花了較多篇幅介紹可以輕鬆透過環境變數啟動的除錯模式選項之一。各位讀者,我們明日再會!
第十三天:更多除錯訊息
- Day: 13
- 發佈日期: 2019-09-28
- 原文: https://ithelp.ithome.com.tw/articles/10221931
前情提要
昨日提到除錯選項的 allocfreetrace,但若要觀察 scheduler 行為,hw 範例還是太單薄了一些。
schedinit
...
parsedebugvars()
gcinit()
sched.lastpoll = uint64(nanotime())
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
...
新的範例:multi-hw.go
為了展示 scheduler 真的有在忙,我們也因此必須準備比較有趣一點的範例。在這個範例當中,我們將令 n 個 goroutine 彼此之間達成總量 n*(n-1) 的訊息交流;只要需要切換這些 goroutine,scheduler 就必然會有用武之地了。
import (
//"fmt"
"os"
"strconv"
"sync"
"sync/atomic"
)
func main() {
n, _ := strconv.Atoi(os.Args[1])
// Init the channels
chans := make([][]chan uint32, n)
shadow := make([]chan uint32, n*n)
for i := 0; i < n; i++ {
chans[i] = shadow[i*n : (i+1)*n]
for j := 0; j < n; j++ {
chans[i][j] = make(chan uint32)
}
}
首先,直接用牛刀殺雞吧!直接宣告一個 n*n 的 channel 陣列,對於每一個符合 0 <= i,j <= n 且 i != j 的數對,i 傳訊給 j 的同步頻道就必須使用 chans[i][j]。然後,由於主要的 main routine 沒有參與,所以也要有個同步機制讓它等候所有的 goroutine 結束,就像 POSIX 的 wait 那樣:
// the ID of each go routine
var id uint32
// main thread waits for all goroutine
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
myID := atomic.AddUint32(&id, 1) - 1
...
}()
}
wg.Wait()
time.Sleep(500 * time.Microsecond)
}
這裡我們使用 sync.WaitGroup 型別的同步物件 API,等待者需使用 Add 方法指定總共要等待多少個 goroutine 結束,而每一個 goroutine 則是需要呼叫 Done 方法表達自己已經結束。最後的 time.Sleep 只是一個保險,畢竟開啟除錯訊息之後還是有可能因為 main thread 離開而印到一半沒有下文。
這一段程式的另外一個重點是 ID。搜尋一下就會發現,其實 GO 語言社群是有意識地不希望 goid 這個資訊暴露在外,所以筆者這裡才會使用一個 atomic 操作來自己生成可供識別的 ID。
var i uint32
for i = 0; i < myID; i++ {
// read from goroutine i
<-chans[i][myID]
// write to goroutine i
chans[myID][i] <- myID
}
for i = myID + 1; i < uint32(n); i++ {
// write to goroutine i
chans[myID][i] <- myID
// read from goroutine i
<-chans[i][myID]
}
迴圈的內層就是實際的交流功能。由於這裡 channel 只有預設的設定,也就是說,無論是讀取或是寫入,都會是 block 的狀態,一定要讀寫成對才能夠繼續運行下去。為了避免 deadlock,這裡的設定是將每一個 goroutine 的 myID 當作輩分,因此有順序性,讀者可以自行驗證。
channel 不能像是 socket programming 一樣,就算兩端都先丟後收也能各自接收到訊息。但是還是有些進階用法能夠組合出 non-blocking 的功能,這裡就先不討論了。有興趣的讀者可以使用 select 當作關鍵字查查看。
那麼就是使用除錯選項 schedtrace 了:
$ GODEBUG=schedtrace=1 ./hw
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
Hello World!
$ GODEBUG=schedtrace=1 ./multi-hw 2048
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0 0 0 0 0]
SCHED 1ms: gomaxprocs=8 idleprocs=6 threads=5 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 2ms: gomaxprocs=8 idleprocs=6 threads=5 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 3ms: gomaxprocs=8 idleprocs=7 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 4ms: gomaxprocs=8 idleprocs=7 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
...
SCHED 601ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 1 1 0 1 0]
SCHED 611ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0 0 0 1 1]
SCHED 621ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 [1 0 0 1 1 1 0 0]
SCHED 632ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 [0 1 1 1 1 1 0 1]
SCHED 642ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 [1 1 0 1 1 0 0 0]
不得不說這個結果還是讓人頗為困惑。gomaxprocs=8 是筆者的電腦的實體核心數,也是可以透過 GOMAXPROCS 環境變數設置可讓 GO 語言程式引用的一個值,這還好理解,但是 idleprocs 的增減或是歸零本身並沒有什麼資訊可言。thread 的部份也很令人疑惑 spinning 的定義是什麼?為什麼後三者加起來還不會等於總 thread 數呢?
所以其實就是情報太簡化了。繼續參考除錯選項文件可以發現還有一個叫做 scheddetail 的選項,以 0 和 1 控制,而其實 schedtrace 的值是代表紀錄 scheduler 工作的時間間隔。開啟了之後大概會有類似以下的結果:
$ GODEBUG=schedtrace=10,scheddetail=1 ./multi-hw 2048 2>&1 | egrep "curg=[0-9]*|m=[0-9]|SCHED"
...
SCHED 564ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0
P0: status=1 schedtick=2651 syscalltick=9 m=0 runqsize=1 gfreecnt=2
P1: status=1 schedtick=2915 syscalltick=6 m=5 runqsize=1 gfreecnt=0
P2: status=1 schedtick=2487 syscalltick=6 m=4 runqsize=1 gfreecnt=0
P3: status=1 schedtick=3072 syscalltick=0 m=8 runqsize=0 gfreecnt=0
P4: status=1 schedtick=2690 syscalltick=0 m=6 runqsize=0 gfreecnt=8
P5: status=1 schedtick=14185 syscalltick=0 m=7 runqsize=1 gfreecnt=0
P6: status=1 schedtick=9973 syscalltick=0 m=3 runqsize=0 gfreecnt=12
P7: status=1 schedtick=2270 syscalltick=0 m=2 runqsize=1 gfreecnt=0
M8: p=3 curg=1714 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
M7: p=5 curg=790 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1
M6: p=4 curg=753 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
M5: p=1 curg=274 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
M4: p=2 curg=217 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1
M3: p=6 curg=795 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1
M2: p=7 curg=276 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1
M0: p=0 curg=1053 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1
G142: status=2(chan send) m=5 lockedm=-1
G962: status=2(chan receive) m=5 lockedm=-1
G1022: status=2(chan receive) m=5 lockedm=-1
G1096: status=2(chan send) m=0 lockedm=-1
G1570: status=2(chan send) m=2 lockedm=-1
G1799: status=2(chan receive) m=2 lockedm=-1
G2075: status=2(chan send) m=6 lockedm=-1
SCHED 656ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0
P0: status=1 schedtick=4179 syscalltick=9 m=0 runqsize=0 gfreecnt=36
P1: status=1 schedtick=4748 syscalltick=6 m=5 runqsize=1 gfreecnt=11
...
之所以使用 egrep 指令篩選輸出,是因為 multi-hw 範例其實執行得非常快,因此不得不開多一點 goroutine 來觀察行為;但真的有很多 goroutine 之後,印出 G 的行數又會大幅稀釋重要資訊。無論如何,從這些 trace 當中可以發現 P-M-G 之間的對應關係,但很難想像如何在真正的除錯過程中派上用場?
還有一個詭異的地方。如果只看 P 和 M 的話,可以看到它們之間還是有很一致的連結性,比方說 M7 的資料中存在一筆 p=5,而 P5 的資料裡面也有一筆 m=7;但如果以為這個案例可以通用到 G,那就想得太美好了,很明顯地在經過 egrep 的篩選之後,由於 m=[0-9] 的通用表示式條件,這裡已經篩出屬於特定 M (若是閒置的 goroutine 會被顯示為 m=-1)的 G 了,但其實他們與 M 的資料完全不一致。這又是為什麼?難道是為了不過份影響效能,因此在 trace 輸出時,整個 goroutine 的排程也繼續照常進行嗎?
這些 trace 的設置
由於疑問實在太多,筆者決定再稍微深入一點,觀察這些除錯選項如何實際起作用;也就是說,它們如何被轉化成印出這些訊息的條件?這些印出的內容,又是從什麼樣的結構體當中撈取資訊的?
直接用 debug.schedtrace 當關鍵字搜尋整個 src 資料夾,可以得到以下結果:
./runtime/runtime1.go:334: {"scheddetail", &debug.scheddetail},
./runtime/runtime1.go:335: {"schedtrace", &debug.schedtrace},
./runtime/panic.go:919: if debug.schedtrace > 0 || debug.scheddetail > 0 {
./runtime/proc.go:4297: if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {
./runtime/proc.go:4367: if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
./runtime/proc.go:4369: schedtrace(debug.scheddetail > 0)
前兩者是在 parsedebugvars 函式本體之前的定義所在之處。後面這幾項目就是我們要找的實際產生作用之處了。先看 panic 處理的條件,顯然是這兩個選項開啟時才會作用,
913 switch \_g\_.m.dying {
914 case 0:
915 // Setting dying >0 has the side-effect of disabling this G's writebuf.
916 \_g\_.m.dying = 1
917 atomic.Xadd(&panicking, 1)
918 lock(&paniclk)
919 if debug.schedtrace > 0 || debug.scheddetail > 0 {
920 schedtrace(true)
921 }
922 freezetheworld()
這是在 startpanic_m 函式之中的一個片段,g 這個 goroutine 所屬的 M 要進入 panic 狀態的其中一種情況。這裡呼叫的 schedtrace 函式應該就是我們要找的對象吧!果不其然:
4504 func schedtrace(detailed bool) {
4505 now := nanotime()
4506 if starttime == 0 {
4507 starttime = now
4508 }
4509
4510 lock(&sched.lock)
4511 print("SCHED ", (now-starttime)/1e6, "ms: gomaxprocs=", gomaxprocs, " idleprocs=", sched.npidle, " threads=", mcount(), " s pinningthreads=", sched.nmspinning, " idlethreads=", sched.nmidle, " runqueue=", sched.runqsize)
4512 if detailed {
4513 print(" gcwaiting=", sched.gcwaiting, " nmidlelocked=", sched.nmidlelocked, " stopwait=", sched.stopwait, " sysmonw ait=", sched.sysmonwait, "\n")
4514 }
4515 // We must be careful while reading data from P's, M's and G's.
4516 // Even if we hold schedlock, most data can be changed concurrently.
4517 // E.g. (p-\>m ? p-\>m-\>id : -1) can crash if p-\>m changes from non-nil to nil.
4518 for i, \_p\_ := range allp {
每一組 trace 的標題行以 SCHED 開頭的印出部份就在這裡確定了。這個註解也解答了我們前面的問題,那就是所有被讀取的資料都是同時在並行執行的;註解中並且舉了一個可能會招致程式 crash 的錯誤模式。這個片段之下有三組大迴圈,分別針對 allp、allm 以及 allg,就是我們都在前幾段看到的那樣了。
疑問
schedtrace真的有實際用途嗎?用在何處?
本日小結
原本打算要完成整個 schedinit 追蹤,結果光是 schedtrace 就看了很久啊。明日繼續努力!
第十四天:schedinit 告一段落
- Day: 14
- 發佈日期: 2019-09-29
- 原文: https://ithelp.ithome.com.tw/articles/10222350
前情提要
原本打算昨日結束整個 schedinit 部份,但光是寫範例程式和追蹤其中的 P-M-G 關係就花了許多時間...
schedinit
...
gcinit()
sched.lastpoll = uint64(nanotime())
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
...
gcinit 函式(runtime/mgc.go)
GC(Garbage Collection)!這可以算是 C 母語的筆者覺得最魔幻的一個元件之一了。與其下來直接看程式,更有效的方式應該是先閱讀一些資料。筆者這裡推薦這些內容:
- GO 語言與它的垃圾回收機制
這投影片雖然是英文的,但是有些圖例實在畫龍點睛。這裡推薦幾頁給各位讀者瀏覽:首先是第 7 頁的問題介紹,到第 10 頁為止展示一個簡單的記憶體操作範例,有個節點 A 原本連到節點 B,但後來又直接生成了一個新的節點 C 取代原本 B 所在的連結,B 節點的位址登時成為再也無法存取到的無主之地,這種情況就是 GC 必須出面處理的了,否則程式規模一大,這樣的案例要是全部不回收的話,再多的記憶體也不夠用。
接著是兩種派別的垃圾回收機制,reference counting 和 tracing。GO 語言採用的是屬於後者,從 21 頁開始。簡單看過描述之後可以跳到 27 頁,那裡描述 GO 語言採用的三色方法,非常有趣。簡單來說就是,整個可管理的記憶體空間相當於是彼此存取的物件所形成的一張有向圖。其中,所有的物件都可以被區分成三個不同的陣營;第一個是已經被掃描過、確定還在被使用的,這是白色;第二個是可以從黑色陣營物件透過指標存取得到、但是還沒被掃描到的物件,這是灰色;最後,已經完全失去存取手段的物件,是白色。
這個演算法就是不斷的從黑色陣營的物件中檢查它們存取得到的物件,並把屬於灰色陣營的物件吸納到黑色部份去。等到灰色集合空了之後,就可以確定白色集合是可以回收的物件了。
第 42 頁開始有 GO 語言垃圾回收機制的沿革。到 1.5 版之後的效能已經突飛猛進,STW 時間變得更少,且還有部份回收過程可以與應用程式一起並行。
- Garbage Collection Sematics(GopherCon SG 2019)
這篇是今年新加坡 GopherCon 的演講,有非常口語且簡潔的說明,只有 25 分鐘長度,值得一看!影片中也有提到GODEBUG環境變數的垃圾回收選項,會紀錄每一次垃圾回收的一些資訊,有興趣的讀者不妨試試看吧!
程式碼本身
整個 mgc.go 檔案的前面有很大篇幅的註解,從比較高層次的角度解釋 GC 在做什麼。有很多關鍵字:tri-color、on-the-fly、mark-and-sweep 之類,各自有各自的用意,在上一段介紹的演說影片中也都有大概提及。但由於 Mark 和 Sweep 兩個動作常常出現在程式碼中,這裡還是簡單說明一下。前段簡單描述過三色演算法的概念,那大致上就是 Mark 的部份,將各個記憶體物件標記起來;之後,只要根據標記回收即可,所以 Sweep 階段有很大比例都是可以與應用程式並行的。
if unsafe.Sizeof(workbuf{}) != _WorkbufSize {
throw("size of Workbuf is suboptimal")
}
// No sweep on the first cycle.
mheap_.sweepdone = 1
// Set a reasonable initial GC trigger.
memstats.triggerRatio = 7 / 8.0
// Fake a heap_marked value so it looks like a trigger at
// heapminimum is the appropriate growth from heap_marked.
// This will go into computing the initial GC goal.
memstats.heap_marked = uint64(float64(heapminimum) / (1 + memstats.triggerRatio))
gcinit 內其實主要就是一些初始化參數的設定。試想,從之前的資料推測的話,垃圾回收應該也是執行時完全獨立於使用者應用程式邏輯,依照某些我們目前尚且未知的條件所觸發的一種背景機制。所以若是將記憶體的配置與(使用垃圾回收機制)回收比喻作人體肌肉的充能與耗損過程、且將程式的執行比喻做一場賽跑的話,gcinit 就是在起跑點上蓄勢待發時的狀態而已。
第一組要確認的條件是 workbuf 的大小。可是這實在很奇怪,難道這種緩衝區大小不是應該隨著平台的大小而調整的嗎?但是兩個值都存在於 runtime/mgcwork.go 檔案中,都是由所有架構共用的。這個值是 2KB,有明確的定義
const (
_WorkbufSize = 2048 // in bytes; larger values result in less contention
// workbufAlloc is the number of bytes to allocate at a time
// for new workbufs. This must be a multiple of pageSize and
// should be a multiple of _WorkbufSize.
//
// Larger values reduce workbuf allocation overhead. Smaller
// values reduce heap fragmentation.
workbufAlloc = 32 << 10
)
且將這部份留作疑問。接下來分別設置了 mheap_ 與 memstats 的一些條件。其中 mheap_sweepdone 當然是一個標準的初始條件,因為最一開始,當然不應該有任何相當於 sweep 階段的回收工作。memstats 相關的兩個條件這裡就先放著,從註解中看來是與垃圾回收機制在每個觸發階段的工作目標有關。
剩下的 gcinit 部份:
// Set gcpercent from the environment. This will also compute
// and set the GC trigger and goal.
_ = setGCPercent(readgogc())
...
func readgogc() int32 {
p := gogetenv("GOGC")
if p == "off" {
return -1
}
if n, ok := atoi32(p); ok {
return n
}
return 100
}
這個 setGCPercent 函式是極其重要的一個呼叫(位於 runtime/mgc.go 之中)。在這裡它先取得了來自 GOGC 環境變數的設置,通常這可以設置一個數值或是 off 代表關閉,預設是 100。因為整個觸發機制仰賴一個百分比的比率,100% 意味著原汁原味的預設值。至於是什麼的預設值?其實相關資訊就在 gcinit 上方不遠的註解:
// During initialization this is set to 4MB*GOGC/100. In the case of
// GOGC==0, this will set heapminimum to 0, resulting in constant
// collection even when the heap size is small, which is useful for
// debugging.
var heapminimum uint64 = defaultHeapMinimum
// defaultHeapMinimum is the value of heapminimum for GOGC==100.
const defaultHeapMinimum = 4 << 20
也就是最小的 heap 記憶體量值的意思。相較於垃圾回收機制比值的相關註解,最後兩行顯得非常低調:
...
work.startSema = 1
work.markDoneSema = 1
}
這個 work 是一個龐大的結構,定義在同一個檔案中,詳細內容就先略過了。這兩個成員變數的共同點在於後綴的 Sema 到底是指什麼?翻找了一下原始定義,原來是旗標(semaphore):
// startSema protects the transition from "off" to mark or
// mark termination.
startSema uint32
// markDoneSema protects transitions from mark to mark termination.
markDoneSema uint32
它們分別保護了垃圾回收過程中的一些狀態轉移的部份,這裡就提及了 off、mark、mark termination 等階段。同一個檔案之中還有 gcStart 之類的垃圾回收功能的核心函式,這裡就先不深入。
gcinit 最後的一些部份
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
使用者可以透過 GOMAXPROCS 控制 GO 程式所能使用的最多程序數量。在 procresize 函式中可以看到,
// Change number of processors. The world is stopped, sched is locked.
// gcworkbufs are not being modified by either the GC or
// the write barrier code.
// Returns list of Ps with local work, they need to be scheduled by the caller.
func procresize(nprocs int32) *p {
old := gomaxprocs
if old < 0 || nprocs <= 0 {
throw("procresize: invalid arg")
}
GO 語言的三項之力 P-M-G 之中的 P 資源會在這裡變動,而我們這裡就是作為初始化之用。其後會為 sched 排程器設置一些參數,然後很大篇幅在處理 allp 這個全域變數。
疑問
- 為什麼
workbuf的大小綁定 2K 呢? allp的處理是看到了,那allm和allg呢?
本日小結
今日終於完結了 schedinit 的追蹤部份。明日開始我們就繼續往 main 函式前進吧!
第十五天:追蹤 newproc
- Day: 15
- 發佈日期: 2019-09-30
- 原文: https://ithelp.ithome.com.tw/articles/10223003
前情提要
昨日終於結束了 schedinit 部份,主要是追 gcinit 函式,還有從比較抽象的角度瀏覽了一些垃圾回收的機制。
退回一層
回到我們之前所在的 runtime/asm_amd64.S:
...
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
...
官方的組語文件提供非常好的指引!比方說這裡的
SB就是一個虛擬暫存器,代表靜態的 base 位址,用來表達全域的 symbol。
以 Hello World 範例而言,這一段被轉換成真正的 x86_64 組語之後變成這樣:
4512d0: e8 6b ac fd ff callq 42bf40 <runtime.schedinit>
4512d5: 48 8d 05 cc 76 08 00 lea 0x876cc(%rip),%rax # 4d89a8 <runtime.mainPC>
4512dc: 50 push %rax
4512dd: 6a 00 pushq $0x0
4512df: e8 ac 14 fe ff callq 432790 <runtime.newproc>
4512e4: 58 pop %rax
4512e5: 58 pop %rax
看這個產出結果,說 GO 的 IR 組語是 x86 的親兒子真不為過,根本是一對一的超完美對應。那先查查看這個 mainPC 本尊何處?
整個 src 甚至整個 GO 語言資料夾都看過了,含有 mainPC 這個字串的都只有 src/runtime 底下的各個架構相依檔案有而已,如
../src/runtime/asm_386.s: PUSHL $runtime·mainPC(SB) // entry
../src/runtime/asm_386.s:DATA runtime·mainPC+0(SB)/4,$runtime·main(SB)
../src/runtime/asm_386.s:GLOBL runtime·mainPC(SB),RODATA,$4
../src/runtime/asm_mipsx.s: MOVW $runtime·mainPC(SB), R1 // entry
../src/runtime/asm_mipsx.s:DATA runtime·mainPC+0(SB)/4,$runtime·main(SB)
../src/runtime/asm_mipsx.s:GLOBL runtime·mainPC(SB),RODATA,$4
...
它們都是只有三行,第一道取得 runtime.mainPC,無論是到記憶體或是到暫存器。再來就是定義,帶有 DATA 標籤意味著它是全域變數,且初始化到逗點之後,斜線看起來像是除法的那個語法結構代表著它的資料寬度。通常後面都會直接跟著 GLOBL 標籤,同時附上它所屬的區段(這裡剛好也可以完全對應到 ELF 的 .rodata),最後一個參數指定資料寬度。
我大 RISC-V 沒有原生 PUSH/POP 不就超麻煩?這也只能後續再研究了。
runtime.newproc
根據註解,編譯器會把所有的 go statement(應該就是那些非同步的 GO-style spawn)轉換成呼叫這個函式。這個函式會創造一個 g(goroutine),並且將傳入的函式加入到那個 g 的等待佇列之中。另外還有特別提到由於 stack 中的參數排列按照順序,因此「不可以切分 stack(cannot split the stack)」,並且附帶 //go:nosplit 的編譯器選項,大概類似告訴 C 語言編譯器不要作某些最佳化一樣吧?
函式 newproc 一樣在 runtime/proc.go 之中,內容如下:
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, gp, pc)
})
}
這個函式吃兩個參數,對照前段的話,應該就是 siz = 0x0、fn = &runtime.mainPC 這樣的配置吧?用好久沒有拿出來秀的 gdb 觀察看看:
$ gdb -d $(pwd) -ex "add-auto-load-safe-path /home/noner/FOSS/2019ITMAN/go/src/runtime/runtime-gdb.py" ./hw
...
(gdb) b runtime.newproc
Breakpoint 1 at 0x432790: file /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go, line 3251.
(gdb) run
Starting program: /home/noner/FOSS/2019ITMAN/go/src/hw
Breakpoint 1, runtime.newproc (siz=0, fn=<optimized out>) at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:3251
3251 func newproc(siz int32, fn *funcval) {
(gdb) bt
#0 runtime.newproc (siz=0, fn=<optimized out>) at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:3251
#1 0x00000000004512e4 in runtime.rt0_go () at /home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:220
#2 0x0000000000000001 in ?? ()
#3 0x00007fffffffdf08 in ?? ()
#4 0x0000000000000001 in ?? ()
#5 0x00007fffffffdf08 in ?? ()
#6 0x0000000000000000 in ?? ()
(gdb)
使用 bt 展示呼叫順序,顯示這次停下的斷點應該與我們正在追蹤的進度一致,只是它竟然說 fn 已經被最佳化掉了,這該怎麼辦?沒關係,反正我們知道正解,這個 fn 應該要是 &runtime.mainPC,事實上應該也可以相信幾個段落前用 objdump 工具獲得的結果:0x4d89a8。先射箭再畫靶,我們倒是看看這個值被優化到哪裡去了......
堆疊狀態?
重新啟動一個 gdb 除錯階段,在進入 newproc 函式之前就先停下來慢慢考察:
Breakpoint 1, runtime.rt0_go () at /home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:217
217 MOVQ $runtime·mainPC(SB), AX // entry
(gdb) x/4i $pc
=> 0x4512d5 <runtime.rt0_go+293>: lea 0x876cc(%rip),%rax # 0x4d89a8 <runtime.mainPC>
0x4512dc <runtime.rt0_go+300>: push %rax
0x4512dd <runtime.rt0_go+301>: pushq $0x0
0x4512df <runtime.rt0_go+303>: callq 0x432790 <runtime.newproc>
(gdb) display/x $rsp
1: /x $rsp = 0x7fffffffded0
(gdb) display/i $pc
2: x/i $pc
=> 0x4512d5 <runtime.rt0_go+293>: lea 0x876cc(%rip),%rax # 0x4d89a8 <runtime.mainPC>
(gdb) si
218 PUSHQ AX
1: /x $rsp = 0x7fffffffded0
2: x/i $pc
=> 0x4512dc <runtime.rt0_go+300>: push %rax
(gdb) p/x $rax
$1 = 0x4d89a8
push 之前,確實是在 rax 暫存器中已經存放了 mainPC 的位址。
(gdb) si
219 PUSHQ $0 // arg size
1: /x $rsp = 0x7fffffffdec8
2: x/i $pc
=> 0x4512dd <runtime.rt0_go+301>: pushq $0x0
x86_64 的堆疊處理慣例是 push 時實際數值減少,也就是往前擺放,所以這裡可以看到 rsp 暫存器減少,且減少之後的那個位址(這裡是 0x7fffffffdec8)當中就會存放著 0x4d89a8 的值。
(gdb) si
220 CALL runtime·newproc(SB)
1: /x $rsp = 0x7fffffffdec0
2: x/i $pc
=> 0x4512df <runtime.rt0_go+303>: callq 0x432790 <runtime.newproc>
一樣的操作,將數值的 0 推到位址 0x7fffffffdec0 之中。
(gdb) si
runtime.newproc (siz=0, fn=<optimized out>) at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:3251
3251 func newproc(siz int32, fn *funcval) {
1: /x $rsp = 0x7fffffffdeb8
2: x/i $pc
=> 0x432790 <runtime.newproc>: sub $0x40,%rsp
(gdb)
咦?可是呼叫且進入 newproc 函式之後,竟然看見 rsp 暫存器又推了 8 byte 走?哈哈,筆者這是太久沒看 x86_64 大驚小怪了。由於這個架構沒有特地為回傳位址設計一個 ra,所以只好在堆疊上再耗一個空間來存,檢視看看便知是否如此:
(gdb) x/4gx $rsp
0x7fffffffdeb8: 0x00000000004512e4 0x0000000000000000
0x7fffffffdec8: 0x00000000004d89a8 0x0000000000000001
(gdb) x/10i 0x00000000004512e4
0x4512e4 <runtime.rt0_go+308>: pop %rax
0x4512e5 <runtime.rt0_go+309>: pop %rax
0x4512e6 <runtime.rt0_go+310>: callq 0x42db50 <runtime.mstart>
...
沒錯!正是如此!事實證明進入 newproc 函式的瞬間,rsp 中存放著回傳位址。
深入解析 newproc
繼續回來看組語吧。
0000000000432790 <runtime.newproc>:
432790: 48 83 ec 40 sub $0x40,%rsp
432794: 48 89 6c 24 38 mov %rbp,0x38(%rsp)
432799: 48 8d 6c 24 38 lea 0x38(%rsp),%rbp
...
4327fe: 48 8b 6c 24 38 mov 0x38(%rsp),%rbp
432803: 48 83 c4 40 add $0x40,%rsp
432807: c3 retq
這只是先幫各位讀者把 prologue 和 epilogue 剝開來。這個函式一開始就將 rsp 暫存器挪移了 0x40 的量,相當於是宣告它需要 8 個 64-bit 整數的空間的意思。其中立刻拿來使用的是 0x38(%rsp),這個東西儲存了 rbp,也就是 x86 呼叫慣例當中的 frame pointer;儲存了現在的 rbp 之後,就立刻將 0x38(%rsp) 的位址載入到 rbp 中。但可能是因為這是第一次進入到有遵照慣例的函式呼叫?這時候的 rbp 其實是 0。
x86 的呼叫慣例像是
rbp與rsp的雙人舞。
為了避免混淆,我們可以先列表表示當前的堆疊狀態如下:
+-------------+--------------------+--------------------+
| 位址 | 實際意義 | 實際內容 |
+-------------+--------------------+--------------------+
|7fffffffdec8 |第二個參數 fn | 0x4d89a8 |
|7fffffffdec0 |第一個參數 siz | 0 |
|7fffffffdeb8 |`newproc` 回傳位址 | 0x4512e4 |
+-------------+--------------------+--------------------+
|7fffffffdeb0 |old rbp | 0 |`newproc` 函式的 frame
|7fffffffdea8 |0x30(new rsp) | ?? |
|7fffffffdea0 |0x28(new rsp) | ?? |
|7fffffffde98 |0x20(new rsp) | ?? |
|7fffffffde90 |0x18(new rsp) | ?? |
|7fffffffde88 |0x10(new rsp) | ?? |
|7fffffffde80 |0x08(new rsp) | ?? |
|7fffffffde78 |0x00(new rsp) | ?? |
+-------------+--------------------+--------------------+
接下來的內容,就是 newproc 函式如何使用它所配置的這些空間了。回頭看一下程式碼:
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, gp, pc)
}
到底為什麼會用到額外的七個整數的空間呢?這裡可以看到,取得 argp、gp、以及 pc 三個變數,實際上是為了執行無名函式。這個無名函式純粹作為 systemstack 的唯一參數(所以這裡應該會佔掉一個 8 byte),並且它本體只直接呼叫了 newproc1,而這裡帶有五個參數。還欠一個在哪裡?沒關係我們慢慢看:
43279e: 64 48 8b 04 25 f8 ff mov %fs:0xfffffffffffffff8,%rax
4327a5: ff ff
4327a7: 0f 57 c0 xorps %xmm0,%xmm0
4327aa: 0f 11 44 24 08 movups %xmm0,0x8(%rsp)
4327af: 0f 11 44 24 18 movups %xmm0,0x18(%rsp)
4327b4: 0f 11 44 24 28 movups %xmm0,0x28(%rsp)
4327b9: 48 8d 0d 50 e3 01 00 lea 0x1e350(%rip),%rcx # 450b10 <runtime.newproc.func1>
4327c0: 48 89 4c 24 08 mov %rcx,0x8(%rsp)
4327c5: 48 8d 4c 24 50 lea 0x50(%rsp),%rcx
4327ca: 48 89 4c 24 10 mov %rcx,0x10(%rsp)
4327cf: 48 8d 4c 24 58 lea 0x58(%rsp),%rcx
4327d4: 48 89 4c 24 18 mov %rcx,0x18(%rsp)
4327d9: 8b 4c 24 48 mov 0x48(%rsp),%ecx
4327dd: 89 4c 24 20 mov %ecx,0x20(%rsp)
4327e1: 48 89 44 24 28 mov %rax,0x28(%rsp)
4327e6: 48 8b 44 24 40 mov 0x40(%rsp),%rax
4327eb: 48 89 44 24 30 mov %rax,0x30(%rsp)
4327f0: 48 8d 44 24 08 lea 0x8(%rsp),%rax
4327f5: 48 89 04 24 mov %rax,(%rsp)
4327f9: e8 f2 eb 01 00 callq 4513f0 <runtime.systemstack>
0x43279e 使用了 fs 這個 x86 傳統上稱為 segment register 的暫存器,通常都是被當作 TLB 來使用。GO 語言裡面,這個 -8 的存取位址結果正是當前的 goroutine 的位址,因此其實這裡對應到的是原始程式碼中的 gp := getg();又,這個 rax 一直到 0x4327e1 才被放入 0x28(%rsp)。為什麼是排到第六個的 0x28?它難道不是應該是 newproc1 的第四個參數嗎?先繼續看下去吧。
中間一段突然冒出非通用暫存器的
xmm0的操作。這是 SSE 擴充的 128 byte 暫存器,這裡應該只是在將需要用到的部份清空為零而已,請讀者自行檢驗。
疑問
rbp在剛進入newproc函式時是 0,合理嗎?- xmm0 的操作是怎麼回事?
本日小結
開始追蹤 newproc,算是直接面對 GO 語言赤裸裸的樣貌吧,但是有 C 的基礎的話這些也不算難以理解。雖然斷在這裡很奇怪,但今天感覺也已經很夠了。各位讀者,我們明日再會!
第十六天:newproc1 之前的堆疊準備動作
- Day: 16
- 發佈日期: 2019-10-01
- 原文: https://ithelp.ithome.com.tw/articles/10223431
前情提要
昨日開始了 newproc 函式,概念上應該是要準備一個新的 goroutine 準備執行?通常是用在 go statement 的生成,但是這裡是第一次,理論上是要準備用來生成 main 函式的執行
現在堆疊狀態
+-------------+--------------------+--------------------+
| 位址 | 實際意義 | 實際內容 |
+-------------+--------------------+--------------------+
|7fffffffdec8 |第二個參數 fn | 0x4d89a8 |
|7fffffffdec0 |第一個參數 siz | 0 |
|7fffffffdeb8 |`newproc` 回傳位址 | 0x4512e4 |
+-------------+--------------------+--------------------+
|7fffffffdeb0 |old rbp | 0 |`newproc` 函式的 frame
|7fffffffdea8 |0x30(new rsp) | ?? |
|7fffffffdea0 |0x28(new rsp) gp | 0x55db00 |
|7fffffffde98 |0x20(new rsp) | ?? |
|7fffffffde90 |0x18(new rsp) | ?? |
|7fffffffde88 |0x10(new rsp) | ?? |
|7fffffffde80 |0x08(new rsp) | ?? |
|7fffffffde78 |0x00(new rsp) | ?? |
+-------------+--------------------+--------------------+
剩下的部份
4327b9: 48 8d 0d 50 e3 01 00 lea 0x1e350(%rip),%rcx # 450b10 <runtime.newproc.func1>
4327c0: 48 89 4c 24 08 mov %rcx,0x8(%rsp)
4327c5: 48 8d 4c 24 50 lea 0x50(%rsp),%rcx
4327ca: 48 89 4c 24 10 mov %rcx,0x10(%rsp)
4327cf: 48 8d 4c 24 58 lea 0x58(%rsp),%rcx
4327d4: 48 89 4c 24 18 mov %rcx,0x18(%rsp)
4327d9: 8b 4c 24 48 mov 0x48(%rsp),%ecx
4327dd: 89 4c 24 20 mov %ecx,0x20(%rsp)
4327e1: 48 89 44 24 28 mov %rax,0x28(%rsp)
4327e6: 48 8b 44 24 40 mov 0x40(%rsp),%rax
4327eb: 48 89 44 24 30 mov %rax,0x30(%rsp)
4327f0: 48 8d 44 24 08 lea 0x8(%rsp),%rax
4327f5: 48 89 04 24 mov %rax,(%rsp)
4327f9: e8 f2 eb 01 00 callq 4513f0 <runtime.systemstack>
0x4327b9 這裡取址 &runtime.newproc.func1,在原本程式碼中完全不見蹤影,哪裡有什麼 func1?其實就是那個作為 systemstack 唯一的參數的無名函式,可以在反組譯當中繼續搜尋其蹤跡:
0000000000450b10 <runtime.newproc.func1>:
450b10: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx
450b17: ff ff
450b19: 48 3b 61 10 cmp 0x10(%rcx),%rsp
450b1d: 76 4a jbe 450b69 <runtime.newproc.func1+0x59>
450b1f: 48 83 ec 30 sub $0x30,%rsp
450b23: 48 89 6c 24 28 mov %rbp,0x28(%rsp)
450b28: 48 8d 6c 24 28 lea 0x28(%rsp),%rbp
450b2d: 48 8b 42 10 mov 0x10(%rdx),%rax
450b31: 8b 4a 18 mov 0x18(%rdx),%ecx
450b34: 48 8b 5a 20 mov 0x20(%rdx),%rbx
450b38: 48 8b 72 28 mov 0x28(%rdx),%rsi
450b3c: 48 8b 52 08 mov 0x8(%rdx),%rdx
450b40: 48 8b 12 mov (%rdx),%rdx
450b43: 48 89 14 24 mov %rdx,(%rsp)
450b47: 48 89 44 24 08 mov %rax,0x8(%rsp)
450b4c: 89 4c 24 10 mov %ecx,0x10(%rsp)
450b50: 48 89 5c 24 18 mov %rbx,0x18(%rsp)
450b55: 48 89 74 24 20 mov %rsi,0x20(%rsp)
450b5a: e8 b1 1c fe ff callq 432810 <runtime.newproc1>
450b5f: 48 8b 6c 24 28 mov 0x28(%rsp),%rbp
450b64: 48 83 c4 30 add $0x30,%rsp
450b68: c3 retq
450b69: e8 32 09 00 00 callq 4514a0 <runtime.morestack>
450b6e: eb a0 jmp 450b10 <runtime.newproc.func1>
本來預期裡面只會有一個 runtime.newroc1 的函式呼叫,意外發現無名函式似乎和一般函式的呼叫慣例不太一樣?這裡一樣有取得 %fs:-8 的 goroutine 動作,但不同的是會去和 rsp 的值比較;要是較小的話,就跳到 0x450b96 的地方呼叫 morestack 函式。這不是很有道理嗎?要是呼叫到一個函式開頭發現似乎空間不夠我使用,那麼當然要設法取得更多堆疊。取得之後,一個豪邁的跳躍指令直接跳回自己,但是這不會有無條件遞迴而無法中止的問題,因為並沒有加深堆疊。
無論如何,這個無名函式的進入點位址就這樣被存入 0x8(rsp) 中了。接下來是
4327c5: 48 8d 4c 24 50 lea 0x50(%rsp),%rcx
4327ca: 48 89 4c 24 10 mov %rcx,0x10(%rsp)
昨日很清楚有提到這個函式只配置了八個 8 byte 整數空間並且相對應地挪移了 rsp,但為什麼這裡竟然可以將 0x50(rsp) 的位址當作參數來傳遞呢?且讓我們重新回顧堆疊圖表:
+-------------+--------------------+--------------------+
| 位址 | 實際意義 | 實際內容 |
+-------------+--------------------+--------------------+
|7fffffffdec8 |第二個參數 fn | 0x4d89a8 | ====> 數了 0x50 的話剛好是這個東西!也就是 &runtime.mainPC。
|7fffffffdec0 |第一個參數 siz | 0 |
|7fffffffdeb8 |`newproc` 回傳位址 | 0x4512e4 |
+-------------+--------------------+--------------------+
|7fffffffdeb0 |old rbp | 0 |`newproc` 函式的 frame
|7fffffffdea8 |0x30(new rsp) | ?? |
|7fffffffdea0 |0x28(new rsp) gp | 0x55db00 | //註:這個時候尚未寫入
|7fffffffde98 |0x20(new rsp) | ?? |
|7fffffffde90 |0x18(new rsp) | ?? |
|7fffffffde88 |0x10(new rsp) | ?? |
|7fffffffde80 |0x08(new rsp) func1 | 0x450b10 |
|7fffffffde78 |0x00(new rsp) | ?? |
+-------------+--------------------+--------------------+
該位置對應到先前準備好的 fn,也就是說其實我們隱隱約約開始發現了,無名函式的本體內容應該在這裡也會有對應關係
newproc1(fn, (*uint8)(argp), siz, gp, pc)
// fn => 0x10(rsp)
//
//
// gp => 0x28(rsp)
//
可以預期剩下的三個參數應該也可以這樣被配置進去。緊接著的兩組也是越過當前函式框架的存取:
4327cf: 48 8d 4c 24 58 lea 0x58(%rsp),%rcx
4327d4: 48 89 4c 24 18 mov %rcx,0x18(%rsp)
4327d9: 8b 4c 24 48 mov 0x48(%rsp),%ecx
4327dd: 89 4c 24 20 mov %ecx,0x20(%rsp)
咦?方才的 0x50 已經是我們有紀錄以來的最遠之處,這裡竟然要放 0x58?這其實也呼應了昨日介紹 newproc 註解時提到的,我們希望執行 fn 函式之前的這些處理「Cannot split the stack」,因為 fn 之後其實就會放置他所需要的參數,也正是這裡看見的名為 argp 的變數:這是一個參數列表的起始位址。再來 0x48 對應到傳入前的 siz,當然也沒有問題;另一個證據是,這裡不是用 rcx 而是用 4 byte 版本的 ecx,顯然就是。
再來是
4327e1: 48 89 44 24 28 mov %rax,0x28(%rsp)
4327e6: 48 8b 44 24 40 mov 0x40(%rsp),%rax
4327eb: 48 89 44 24 30 mov %rax,0x30(%rsp)
0x4327e1 沒來由的突然使用 rax 的內容,其實就是稍早已經取得了的 gp。再來是 0x40 的存取,這相當於是 newproc 的回傳位址。稍微更新一下堆疊狀態如下:
+-------------+--------------------+--------------------+
| 位址 | 實際意義 | 實際內容 |
+-------------+--------------------+--------------------+
|7fffffffdec8 |第二個參數 fn | 0x4d89a8 |
|7fffffffdec0 |第一個參數 siz | 0 |
|7fffffffdeb8 |`newproc` 回傳位址 | 0x4512e4 |
+-------------+--------------------+--------------------+
|7fffffffdeb0 |old rbp | 0 |`newproc` 函式的 frame
|7fffffffdea8 |0x30(new rsp) pc | 0x4512e4 |
|7fffffffdea0 |0x28(new rsp) gp | 0x55db00 |
|7fffffffde98 |0x20(new rsp) siz | 0 |
|7fffffffde90 |0x18(new rsp) argp | 0x7fffffffded0 |
|7fffffffde88 |0x10(new rsp) fn | 0x7fffffffdec8 |
|7fffffffde80 |0x08(new rsp) func1 | 0x450b10 |
|7fffffffde78 |0x00(new rsp) | ?? |
+-------------+--------------------+--------------------+
最後呼叫 systemstack 前的片段是
4327f0: 48 8d 44 24 08 lea 0x8(%rsp),%rax
4327f5: 48 89 04 24 mov %rax,(%rsp)
4327f9: e8 f2 eb 01 00 callq 4513f0 <runtime.systemstack>
結果是儲存無名函式 func1 的位址!無論如何,接下來就可以往 systemstack 邁進了。
疑問
- 為什麼不能直接傳入
func1就好呢?這樣不是還能省一個記憶體的存取嗎? - 為什麼要傳
newproc的回傳位址給將由無名函式呼叫的newproc1呢?
本日小結
在 newproc 花了很多心思處理這個傳入參數的順序,並一方面使用 gdb 確認推算無誤,可是好像其實沒有追蹤到什麼機制,反而是 x86_64 的組語重新看了一次。不管怎樣,各位讀者,我們明日再會!
第十七天:看看 systemstack 函式呼叫
- Day: 17
- 發佈日期: 2019-10-02
- 原文: https://ithelp.ithome.com.tw/articles/10223877
前情提要
昨日我們以相當貼近記憶體的方式看完了 newproc 函式,主要是在兜出後續呼叫所需要的參數,為接下來的 systemstack 呼叫作準備。
進入 systemstack
newproc 函式呼叫 systemstack:
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, gp, pc)
})
而 systemstack 又是呼叫到哪裡呢?結果這次撲了個空。在 runtime/stubs.go 裡面只有一個型別的宣告,
//go:noescape
func systemstack(fn func())
大部分的解釋都在上方的註解中。大意是,systemstack 會執行 fn 函式(而且還記得嗎?從參數列表看來,實際上傳入的是某個存放該無名函式指標的變數的指標)。然後有些不同的判斷,根據所呼叫的 goroutine 而定,會有不同的應對方式:
g0(註解中稱之為 per-OS-thread)- `gsignal
- normal g
若是前兩者的話,就直接執行fn函式並回傳。若否,則必須切換到系統堆疊執行該函式。另外註解中也提到,無名函式的使用方式是常用作法,因為其中的變數其實仍然可以享有整個函式的視野,就像newproc的gp等變數可以直接在無名函式內部使用一樣;若是無名函式內部有一些回傳值或是賦予值的變數,也可以在後續拿出來使用。
然而,在之前擷取的組語片段中,明明是有看到 systemstack 的本體的。這又是怎麼回事呢?
systemstack 本體
一樣使用 objdump 工具來觀察,整個 runtime 函式庫裡面用到這個呼叫的地方還真不少啊。直接搜尋那個位址,得到:
00000000004513f0 <runtime.systemstack>:
4513f0: 48 8b 7c 24 08 mov 0x8(%rsp),%rdi
4513f5: 64 48 8b 04 25 f8 ff mov %fs:0xfffffffffffffff8,%rax
4513fc: ff ff
4513fe: 48 8b 58 30 mov 0x30(%rax),%rbx
451402: 48 3b 43 50 cmp 0x50(%rbx),%rax
451406: 74 78 je 451480 <runtime.systemstack+0x90>
451408: 48 8b 13 mov (%rbx),%rdx
45140b: 48 39 d0 cmp %rdx,%rax
45140e: 74 70 je 451480 <runtime.systemstack+0x90>
451410: 48 3b 83 c0 00 00 00 cmp 0xc0(%rbx),%rax
451417: 75 6f jne 451488 <runtime.systemstack+0x98>
如果要符合註解的邏輯的話,我們這裡看到的兩組 je 指令,應該就是分別判斷當前的 g(應該是在 0x4513f5 那一行取得,放到 rax )是否等於 g0 與 gsignal 吧?所以這樣才能一起跳到 0x451480 的捷徑處理。0x451410 的 cmp 指令則是反面判斷,概念上應該是類似 \_g\_.m.g == g 之類的判斷式,如果不相等的話就進到錯誤處理:
45147f: c3 retq
451480: 48 89 fa mov %rdi,%rdx
451483: 48 8b 3f mov (%rdi),%rdi
451486: ff e7 jmpq *%rdi
451488: 48 8d 05 81 05 ff ff lea -0xfa7f(%rip),%rax # 441a10 <runtime.badsystemstack>
45148f: ff d0 callq *%rax
451491: cd 03 int $0x3
如上面這一段稍微後面一點的片段,0x451480 這裡終於用到進入 systemstack 時取用的 rdi 暫存器。先對它取一次值,這樣會取到 fn 函式指標,之後才以 jmpq 指令跳躍過去。還記得昨日的有一個疑問提到說,為什麼傳入無名函式的時候不能直接傳函數指標,而必須傳儲存該指標的變數的指標嗎?這裡的存取方式如果已經寫成這樣,那麼傳入端當然也就必須配合了;還是這是倒果為因,其實是因為 GO 語言的呼叫慣例,使得這裡一定要這樣子寫呢?
筆者推敲推敲總覺得越來越奇怪。雖然說透過 vim-go 導航工具只有找到 runtime/stubs.go,但這個函式的感覺實在不像是可以光靠編譯器生成的東西,所以再次 grep 一下發現,果然是被定義在架構相依的組語檔案中啊!systemstack 真身在 runtime/asm\_amd64.s 之中,與上段列出的組語部份差不多。值得一提的是,在進入為一般 goroutine 的情況下,上兩段之間省略的部份相當於是 goroutine 的 context switch 操作,有興趣的讀者可以深入追蹤。
軌跡
筆者嘗試用 gdb 直接在 systemstack 下斷點,發現其實早在這之前的 schedinit 就已經呼叫過多次,其中也不乏許多有 heap 關鍵字的呼叫。經過比較逼近的斷點設置方法之後,終於來到這裡。接下來就是要檢驗我們的 Hello World 流程在這裡是怎麼走的。沒有意外的話當然應該是要走 g0 的捷徑路線,然後進入呼叫 newproc1 的無名函式。
(gdb) x/2i $pc
=> 0x451483 <runtime.systemstack+147>: mov (%rdi),%rdi
0x451486 <runtime.systemstack+150>: jmpq *%rdi
(gdb) si
388 JMP DI
(gdb) p/x $rdi
$1 = 0x450b10
(gdb) x/10i $rdi
0x450b10 <runtime.newproc.func1>: mov %fs:0xfffffffffffffff8,%rcx
...
(gdb) si
runtime.newproc.func1 () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:3255
3255 systemstack(func() {
果不其然是這個流程,那麼就讓我們繼續走下去吧!昨日也曾經貼上 runtime.newproc.func1 的組語內容,裡面其實也大多是參數的重新定位與安排,接下來就會進入到 newproc1 函式,我們又回到了 runtime/proc.go 之中。
func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
_g_ := getg()
if fn == nil {
_g_.m.throwing = -1 // do not dump full stacks
throw("go of nil func value")
}
acquirem() // disable preemption because it can be holding p in a local var
siz := narg
siz = (siz + 7) &^ 7
...
一開始先針對傳入的 fn 作判斷,不應該沒有東西;關於 acquirem 函式,雖然語意上可以猜測它的用意,但是與註解之間的連結實在完全沒有頭緒,先跳過。再來是關於 siz 變數的給值,這裡稍微秀了一手 bit 操作魔術。GO 語言的 &^ 運算子是清除 bit 的意思,&^7 也就是 C 語言裡 %8 的意思;先加 7 再執行這個動作的話就會有一個階梯狀的輸出效果:siz=0 時為 0、siz=1~8 時為 8、siz=9~16 時為 16。也就是說這裡要計算出來的值並不是參數的個數,而是參數所佔的大小,在 x86_64 上當然就是 8 byte 為單位了。
疑問
acquirem的註解為何是與p是否被存取有關?心理需要更好的 model 來理解這些 GO 語言的抽象物件了...acquirem和releasem的語意應該要有 atomic 的感覺,為何這裡不需要呢?GO 語言有什麼確保不會發生 race condition 的假設?
本日小結
往更廣泛、更具操控性的 API newproc1 邁進了!然而實在是覺得關於 P-M-G 關係還是不甚了解,有些地方很難理出道理來。也許明天先找一下相關的教學再說?
第十八天:GO 語言運行模型的三項之力
- Day: 18
- 發佈日期: 2019-10-03
- 原文: https://ithelp.ithome.com.tw/articles/10224265
前情提要
昨日我們簡單路過 systemstack 函式之後開始了 newproc1 函式。
暫停回顧 P-M-G 關係
雖然在第 10 天時有簡單介紹註解中的關係,但至今為止筆者覺得這些名詞還是太讓人困惑了,因此還是找些資料來輔佐理解 GO 語言的這三個重要的抽象物件吧。
首先當然還是不得不提今年 COSCUP 由 Ken-Yi Lee 帶來的演講:從原始碼看 GO 語言的並行與排程實現。除了主題與筆者的系列高度相關之外,他所使用的投影片也是相當圖文並茂,適合用來對照 P-M-G 之間的角色。
GO Scheduler
但若是可以接受省略更多細節以求高抽象層的理解的話,我推薦這篇部落格。作者一開始就將這些基本名詞定義出來。GO 語言的執行期環境(runtime)管理三種東西,排程,垃圾回收,以及 goroutine runtime 管理。這篇的主體當然是以排程器為主。
排程的課題很多,但最普遍的程度來講,就是如何把抽象的工作配給具有實體運算能力的單元之上。以 GO runtime 的尺度來說,就是將 goroutine 這種輕量級的工作對應到作業系統的執行緒上面。GO routine 的定義在 runtime/runtime2.go 之中的 struct g。
看起來有數十個成員的
struct g,為何總是被稱作輕量?是與什麼比較之後的結果嗎?
GO 執行期環境會負責紀錄 goroutine 與邏輯執行單元(Logical Processor)之間的對應關係,後者就是我們之前常常看到的 P。P 應該被視為是一種抽象資源,或是 context。使用這些資源之前必須先取得使用權,這麼一來作業系統執行緒(M)才能夠執行 goroutine。
至於這三者之間具體是如何互動的呢?本篇部落格的作者也另外引用了一份投影片來解說一個自 go fn() 起始的流程。最一開始的系統狀態裡面有兩種工作佇列(queue):一種是全域佇列(global queue),另外一種則是 P 佇列(per-P queue),是每個 P 所獨有的佇列。要執行一個 G 的話,M 必須要取得 P 作為 context,然後從 P 的佇列中取出 goroutine 來執行。若是執行完了的話,會有另外一套 job stealing 機制,以求運算支援不至於閒置。
這當然是過度簡化的一種描述,要是有非同步的 signal 會如何處理?如果是阻塞型的系統呼叫又會如何處理?
runtime/proc.go 的起始註解
這裡講解完 G、M、P 之後,就描述排程器的設計理念。其中有一個很重要的平衡,原題為 Worker thread parking/unparking,中文也許翻作執行緒的啟動與休眠較好?無論如何,這個平衡的兩端分別是:使用足夠多的執行緒(M)來確保運算資源的使用率足夠高;過多執行緒運行時需強迫休眠以節省運算資源與功耗。這兩者的平衡之所以困難,是因為以下兩個理由:
- 整個排程器的狀態被刻意設計成分散管理的模式,這裡指的是每個 P 都有自己獨有的佇列。所以不太可能取得整體的工作量負荷情況來決定最佳的排程方式。
- 要達到最佳的排程的話必須要能夠一定程度的預測未來的工作量分配,比方說如果有個新的 goroutine 很快就要生成的話,就不要休眠已經閒置的 M。
過去已經拋棄的設計模式包含:集中化的排程器狀態,這會需要至少一個全域的鎖來保護,因而限制了平行化的擴展性(scalibility)。第二個是直接轉移(direct goroutine handoff)機制,當有一個新的 goroutine 出現且有一個閒置的 P 存在時,立刻啟動一個 M,並將 M 與 G 的運行排入 P 的佇列中。這樣會造成 M 的狀態過於快速的更迭(thrashing),因為也許 G 很快就會運行至一個段落。另一個缺點則是這會破壞 G 可能存在的局域性(locality),因為它被直接交付到位於不同實體核心的執行緒上。第三個是不做直接轉移,但是仍然在有新的 G 生成時生成 M,這樣同樣會造成過多的執行緒 thrashing。
所以現在的設計引入一個聰明的概念,叫做空轉(spinning)。如果一個 M 在所屬的 P 佇列與全域佇列都找不到工作,它就被稱為是空轉的,以 m.spinning 與 sched.nmspinning 兩個成員變數表示之。如果一個 GO 程式在任何時間點符合
- 有個閒置的 P
- 沒有任何空轉的 M
的時候,就多生成一個 M。如果它發現有多的 goroutine 可以執行的話就執行,沒有的話,就準備從初始化的空轉狀態再回到休眠狀態。如果至少有一個空轉的 M,那麼就不多生成 M;如果最後一個空轉的 M 找到工作執行了,就立刻再生成一個 M。這麼一來就可以確保不會在某些執行的時候有執行緒的爆炸成長,同時又可以確定運算資源的利用率足夠高。
難道這樣就不會 thrashing 嗎?之後應該要來監控一下 M 的生成與空轉。
這在設計上的困難點在於,處理執行緒的狀態轉移(空轉到非空轉)時必須非常小心,尤其可能與 goroutine 的生成、或是新喚醒的執行緒事件一起形成 race condition。如果這兩者都沒有做好的話,就可能造成運算資源利用率的低下。
疑問
- goroutine 被認為輕量的理由?
- 執行期排程器的整個運作中,一個 M 是如何面對非同步事件或是阻塞型的系統呼叫?
- 為何現在偏好的 approach 就不會有空轉?還是會有額外的 M 生成,並且在空轉找不到工作之後還是得休眠不是嗎?
本日小結
查了些資料,也試著去讀懂註解,接下來就是與程式碼交叉印證的部份了。明日就繼續理解 newproc1 吧!
第十九天:G 的取得路徑
- Day: 19
- 發佈日期: 2019-10-04
- 原文: https://ithelp.ithome.com.tw/articles/10224649
前情提要
昨日我們都在爬梳註解與其他資料,企圖從比較鳥瞰的角度去觀察排程器與 GO 的系統模型。
acquirem 和 releasem(在 runtime/runtime1.go)
由於越想越在意,還是將前天最後的問題拿出來檢討一番:
//go:nosplit
func acquirem() *m {
_g_ := getg()
_g_.m.locks++
return _g_.m
}
//go:nosplit
func releasem(mp *m) {
_g_ := getg()
mp.locks--
if mp.locks == 0 && _g_.preempt {
// restore the preemption request in case we've cleared it in newstack
_g_.stackguard0 = stackPreempt
}
}
如擷取片段所示,這兩個函式的命名分明相當具有 atomic 感覺,但是其中實作不是那麼回事。這個 locks 成員變數也只是普通的 int32。但是如果我們回想昨天啃註解啃的那麼辛苦,就可以合理猜想,也許是因為 M (worker thread) 不會同時在加或同時在減的緣故吧。姑且就當作這麼回事好了。
繼續 newproc1
前面略過一段與檢查參數大小相關的部份,接著:
_p_ := _g_.m.p.ptr()
newg := gfget(_p_)
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
先取得由當前的 _g_ 所屬的 M(thread)的 P(context),然後呼叫 gfget 函式取得新的 G。這個新的 G 是什麼東西呢?簡單來說就是從一群閒置的 G 裡面取得的其中一個,為此我們可以觀察 gfget 函式(同樣在 runtime/proc.go 之中)的內部,相當難得的,非常容易讀懂:
func gfget(_p_ *p) *g {
retry:
if _p_.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
lock(&sched.gFree.lock)
// Move a batch of free Gs to the P.
for _p_.gFree.n < 32 {
// Prefer Gs with stacks.
gp := sched.gFree.stack.pop()
if gp == nil {
gp = sched.gFree.noStack.pop()
if gp == nil {
break
}
}
sched.gFree.n--
_p_.gFree.push(gp)
_p_.gFree.n++
}
unlock(&sched.gFree.lock)
goto retry
}
也是難得看到一個 C-like 的標籤用法!乍看之下,這個 retry 無論如何是無法避免的,因為只要進入了第一個很長的 if 判斷,之後就必然會走到 goto retry 敘述而重來。也就是說,其實這個反覆重新嘗試的迴圈的中止條件,就是別進入第一個 if。為了翻譯順暢,這裡稍微更動一下順序:
!sched.gFree.stack.empty():如果sched.gFree(全域閒置佇列)的stack(具有 stack 的 G 清單)不為空的話、或者!sched.gFree.noStack.empty():如果sched.gFree的noStack(沒有 stack 的 G 清單)不為空的話
加總起來,就是全域閒置佇列有東西的意思;_p_.gFree.empty():如果這個 P 的本地佇列為空
再統整起來的話就可以理解,這裡是先處理本地為空、全域有閒置 G 的狀況。而且這裡依照具備 stack 與否來區分閒置的 G,以下會看到他們的不同處理方式。
這一段程式碼還另外有全域的鎖保護整個佇列。如果有得搬的話,這個 P 會不論 stack 有無,總之設法搬到 32 個為止。就算無法搬到 32 個,也許先進入了把全域佇列搬空(且其他 P 也未挹注閒置的 G 到全域佇列)的條件之中並 break 離開 for 迴圈,這樣在解鎖、retry 之後也一定能夠通過 if 判斷式,因為這時候本地端佇列一定有 G。另外,考察 push、pop 等資料結構方法的話,不難發現它們是定義給 gList 這種結構使用的,這裡就不深入。
「
retry之後本地端佇列一定有 G」這句話是不是怪怪的呢?是的,邏輯上來講,有可能全域佇列一開始有東西,但是進去之後才發現被拿光了,這時候就會 break 出來並從retry再開始。要是這個狀況一直出現,的確有可能會一直在retry標籤反覆。但是實際上如何,筆者也不能很確定;應該還是會有防止 starvation 的機制?
又,相對於這一段從全域到本地的過程,另外也有一個呼叫 gfpurge,做的是完全相反的事:
func gfpurge(_p_ *p) {
lock(&sched.gFree.lock)
for !_p_.gFree.empty() {
gp := _p_.gFree.pop()
_p_.gFree.n--
if gp.stack.lo == 0 {
sched.gFree.noStack.push(gp)
} else {
sched.gFree.stack.push(gp)
}
sched.gFree.n++
}
unlock(&sched.gFree.lock)
}
這些關於 gList 的資料結構方法可說是簡單明瞭,這裡我們看到一個迴圈重複執行直到這個 P 的本地佇列為空為止,裡面並且有一個分歧條件 if gp.stack.lo == 0 用以作為有無 stack 的依據,分別推進不同的 gList 中。這個 lo 成員變數又是什麼呢?它被定義在 runtime/runtime2.go 之中,
type stack struct {
lo uintptr
hi uintptr
}
這其實就是 GO 語言在執行期使用的 stack 的型別,它的空間範圍是從 lo 到 hi。lo 為零的狀況亦即這個變數體本身還沒有被賦值,因此可以說它是沒有 stack 的。
無論如何,確認本地端有內容之後,就會取得一個 G 並使用。
gp := _p_.gFree.pop()
if gp == nil {
return nil
}
_p_.gFree.n--
然後,如果它是來自 noStack 部份,就必須幫它初始化;反之的情況下,判斷兩種不同的 flag 來決定是否要額外配置特殊的記憶體。
if gp.stack.lo == 0 {
// Stack was deallocated in gfput. Allocate a new one.
systemstack(func() {
gp.stack = stackalloc(_FixedStack)
})
gp.stackguard0 = gp.stack.lo + _StackGuard
} else {
if raceenabled {
racemalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
}
if msanenabled {
msanmalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
}
}
return gp
再回到 newproc1
從 gfget 離開之後,
newg := gfget(_p_)
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
if newg.stack.hi == 0 {
throw("newproc1: newg missing stack")
}
if readgstatus(newg) != _Gdead {
throw("newproc1: new g is not Gdead")
}
在本地與全域佇列都沒有 G 的情況下,出來之後會使用 malg 函式生成一個新的以供使用。
疑問
gfget到底有沒有可能挨餓?- 在
gfget之中,從 gFree.stack 拿到 G 的情況下,那兩種不同的 flag 是什麼?什麼時候可以使用相關功能?
本日小結
閒置的 G 如何在全域與本地之間被處理,經過目前為止的這些追蹤,算是比較有點頭緒了。明天我們再繼續看下去吧!
第二十天:新生 goroutine 的初始狀態
- Day: 20
- 發佈日期: 2019-10-05
- 原文: https://ithelp.ithome.com.tw/articles/10224869
前情提要
昨日我們走過 newproc1 函式的最開頭部份;順利的情況下,能夠取得一個新的 G。
使用 gdb 驗證
我們將斷點設在 runtime.newproc1 開始之後,觀察 gfget 函式裡走過的路徑。經過幾次 n 之後,
runtime.gfget (_p_=0xc00002c000, ~r1=<optimized out>) at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:3476
3476 func gfget(_p_ *p) *g {
...
(gdb) n
3478 if _p_.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
這裡的三個複合條件式如何被展開呢?反組譯一下當前的 pc:
(gdb) x/10i $pc
=> 0x432fb3 <runtime.gfget+163>: mov 0xde8(%rax),%rcx
0x432fba <runtime.gfget+170>: test %rcx,%rcx
0x432fbd <runtime.gfget+173>: jne 0x432fed <runtime.gfget+221>
0x432fbf <runtime.gfget+175>: cmpq $0x0,0x123a51(%rip) # 0x556a18 <runtime.sched+152>
0x432fc7 <runtime.gfget+183>: jne 0x432fd3 <runtime.gfget+195>
0x432fc9 <runtime.gfget+185>: cmpq $0x0,0x123a4f(%rip) # 0x556a20 <runtime.sched+160>
0x432fd1 <runtime.gfget+193>: je 0x432fed <runtime.gfget+221>
0x432fd3 <runtime.gfget+195>: lea 0x123a36(%rip),%rax # 0x556a10 <runtime.sched+144>
0x432fda <runtime.gfget+202>: mov %rax,(%rsp)
0x432fde <runtime.gfget+206>: callq 0x4095b0 <runtime.lock>
一個 test 指令與兩個 cmpq,應該就是這裡的三個條件式了。其中,後面的兩個 cmpq 比較對象接近,應該就是同屬於全域佇列的 sched.gFree 結構化約而來的;另外一個線索則是稍後位於 0x432fde 的 runtime.lock 呼叫,它所欲取得的參數是 &sched.gFree.lock,所以這個判斷應該沒有錯了。接下來使用 si 指令,看看能走到哪裡...
...
(gdb)
3497 gp := _p_.gFree.pop()
結果一路走過三個判斷式之後,跳到了整個 if 結構體之外。語意上,這表示我們經過的判斷是本地端佇列為空並且兩種全域佇列均為空。所以接下來的 pop 方法也註定會拿不到東西,而回傳 nil 離開。
這是否也相當合理呢?從架構相依的部份切入至今,也沒有看到 GO 語言有特別作些什麼操作,讓本地端、全域端有閒置的 G 可以使用。所以以這裡 gfget 的語意來講,拿不到任何閒置的 G 應該是情理之常的。
另外一種情況:gfget 沒有回傳一個可用的 newg
也就是說,我們這裡會面對的是接下來的這一段了。
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
透過 malg 函式(同在 runtime/proc.go 之中),配置一個新 G。
值得注意的是,
malg在這裡並不是第一次被呼叫喔!第一次是我們一週前追蹤很久的schedinit、mcommoninit、mpreinit呼叫順序,然後呼叫到malg取得這個最早的 G。
malg 的內容如下:
func malg(stacksize int32) *g {
newg := new(g)
if stacksize >= 0 {
stacksize = round2(_StackSystem + stacksize)
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
})
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
}
return newg
}
透過 new 關鍵字,配置一塊 g 物件所需的記憶體空間之後,有一個根據 stacksize 是否非負的判別。我們傳進來的路徑是使用 _StackMin 常數(定義在 runtime/stack.go),其值為 2048,是 GO 語言均一的最小堆疊量。_StackSystem 是一個作業系統相依的修正值,Linux 不會使用到因此為 0。round2 函式負責以傳入的數值為基準,回傳一個大於它的最小 2 冪次方數。
stackalloc 函式根據指定的堆疊量配置記憶體,其中有許多條件判斷分別針對不同的需求(大小、來源等等)。這裡可以看到 GO 語言執行期環境在這裡使用 systemstack,因此不會進入非系統堆疊的配置路線;剩下的系統堆疊路徑上,又依其大小有不同的處理。我們剛才才看到這裡傳入的是 2048,所以走的就是小量堆疊的生成的路線。程式碼片段如下:
c := thisg.m.mcache
if stackNoCache != 0 || c == nil || thisg.m.preemptoff != "" {
...
} else {
x = c.stackcache[order].list
if x.ptr() == nil {
stackcacherefill(c, order)
x = c.stackcache[order].list
}
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}
v = unsafe.Pointer(x)
...
return stack{uintptr(v), uintptr(v) + uintptr(n)}
if 的部份並未進入。mcache 依照註解,是每個 P 所獨有的空間,專門給小型的記憶體使用。order 變數在稍早,由傳入的堆疊量的對數值計算出來。顯然這裡的 stackcache 能夠分別提取不同大小的小物件。賦 list 值給 next、將當前的 size 量減少、最後回傳 v (對應到 stack 物件的 lo 成員)開始 n 的一塊空間。
取得這個新的堆疊之後,也會設定兩個 stackGuard 成員,這裡就先跳過了。
狀態切換
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
再來就是 casgstatus 函式了。後兩個傳入參數很明顯是 G 的狀態,它們被定義在 runtime/runtime2.go 裡面。_Gidle 的值為 0,就是現在這個剛生成的狀態;後者的 _Gdead 就稍微複雜一點,它可能表示這個 G 剛離開、存在於閒置佇列、或是剛被初始化,總之是並非正在執行使用者程式碼的狀態,它可能已經配置好堆疊了,也可能還沒有。開頭的 cas 代表比較並同時交換(Compare And Swap),通常會使用 CPU 的原子指令(atomic instruction)支援。無論如何,這裡就是將新的 G 轉換到 _Gdead 的狀態。
有趣的是,有一個特殊的 goroutine 狀態是
_Gscan,它可以與runnable、running、syscall、waiting的狀態標記搭配。顧名思義,這個輔助標記與 GC 有關。正如接下來要看的allgadd函式後註解一樣,將這個新的 G 的狀態設成_Gdead而不帶_Gscan,就可以避免 GC 的機制觸及這個新配置的記憶體部份。
allgadd 函式相當簡單,先是作簡單的錯誤排除(不該在這時候看見狀態為 _Gidle 的 goroutine),再來是在 allglock 的保護區域之內執行 append 這個內建方法。
疑問
malg使用new關鍵字配置所需的記憶體,相關機制為何?所取得的的記憶體應該會在 heap 上。- 關於
mcache,為何註解說是 per-P 結構,這裡卻是由 M 來提取呢? - 兩個
stackGuard分別有什麼用呢?註解中是有解釋,但是還是有點抽象。
本日小結
取得可以用的 G 了!接下來這個 goroutine 要如何開始乘載使用者程式呢?
第二十一天:配置新的 goroutine
- Day: 21
- 發佈日期: 2019-10-06
- 原文: https://ithelp.ithome.com.tw/articles/10225300
前情提要
昨日我們終於確定取得一個新的 G 物件,並且初次見識到 G 的狀態轉移。
快轉一點點
由於接下來的部份有些雜亂,筆者還是跳過了一些部份,大致交待如下:
if newg.stack.hi == 0 {
...
if readgstatus(newg) != _Gdead {
...
totalSize := ...
if usesLR {
...
if narg > 0 {
...
第一個條件式剛好就是昨天帶過的內容,新的 G 已經有了 stack 的初始化,因此這裡不可能是未初始化狀態;第二個條件式則是 G 的狀態,已經透過一個比較並交換運算設置成 _Gdead;接下來中間有一段與 stack 相關的變數賦值,這裡先跳過;usesLR 變數的條件在筆者使用的 x86 平台上不成立;narg 是從一開始就一路傳進來至此的參數,在 rt0_go 當中也有註解寫明這個第一次的 newproc 呼叫使用的參數是 0。
坦承以對:其實筆者跳過的內容多半還是尚未理解的 GC 機制中的 write barrier。如果只是將相關程式碼貼出來再說其實自己什麼也看不懂,似乎也不是很負責任的作法,因此這裡就還是先跳過了。
看起來很可疑的 gostartcallfn
接下來的一段程式碼是:
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fn
大部分的內容都是對 newg 的成員變數或結構體的賦值,只有中間的 gostartcallfn 鶴立雞群,而且它還附帶一個傳入至今無人聞問的 fn 參數!這個 fn 一樣可以追溯到 rt0_go 時傳入的 runtime.mainPC。先看它的內容,在 runtime/stack.go 之中:
func gostartcallfn(gobuf *gobuf, fv *funcval) {
var fn unsafe.Pointer
if fv != nil {
fn = unsafe.Pointer(fv.fn)
} else {
fn = unsafe.Pointer(funcPC(nilfunc))
}
gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
這個函式前的註解說,'''這個函式調整 gobuf 的內容,像是要執行 fn 然後立刻做一個 gosave 那樣''',本身也是迷霧重重。這裡的 gobuf 來自 &newg.sched,看起來包含了名為 sp、pc 等等很像是 context 的東西;事實上,如果查詢 gosave 函式(位在 runtime/asm_amd64.s)的功能,可以發現它是用來轉換執行期環境,可說是 go 語言版本的 setjmp (順帶一題,相當於 longjmp 的則是同在附近的 gogo 函式)。但是這裡說是要調整 gobuf 內容嗎?看起來也不像。
首先是檢查傳入的 funcval 型別的變數是否為空,若是空就讓它呼叫一個 nilfunc
func nilfunc() {
*(*uint8)(nil) = 0
}
看起來也是蠻幽默的一種處理方式,直接對空指標寫值。這裡筆者難免有點好奇,這個機制在 runtime 執行至此的時候已經可以用了嗎?故意把上面的 if-else 判斷式拿掉而一律使用後者的結果,之後執行(這裡的測試方法是整組重編,在使用第一組 toolchain 的階段會踩到這個 signal):
$ ./make.bash
Building Go cmd/dist using /usr/lib/go.
Building Go toolchain1 using /usr/lib/go.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x445ce2]
goroutine 1 [running]:
panic(0x79d040, 0xacf980)
/home/noner/FOSS/2019ITMAN/go/src/runtime/panic.go:722 +0x2c2
runtime.panicmem(...)
/home/noner/FOSS/2019ITMAN/go/src/runtime/panic.go:199
runtime.sigpanic()
/home/noner/FOSS/2019ITMAN/go/src/runtime/signal_unix.go:408 +0x3da
runtime.nilfunc()
/home/noner/FOSS/2019ITMAN/go/src/runtime/stack.go:1073 +0x2
runtime.goexit()
/home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:1375 +0x1
go tool dist: FAILED: /home/noner/FOSS/2019ITMAN/go/pkg/tool/linux_amd64/go_bootstrap install -gcflags=all= -ldflags=all= -i cmd/asm cmd/cgo cmd/compile cmd/link: exit status 2
所以其實 signal handler 已經註冊完了?留在疑問裡面等到之後再想辦法去挖掘吧。無論如何,等到這個判斷結束之後,轉一手進入名字很像的 gostartcall 函式(位在 runtime/sys_x86.go),gdb 追蹤到這裡時的顯示如下:
Breakpoint 1, runtime.gostartcallfn (fv=0x4d4648 <runtime.mainPC>, gobuf=<optimized out>)
at /home/noner/FOSS/2019ITMAN/go/src/runtime/stack.go:1081
...
runtime.gostartcall (fn=0x42a9b0 <runtime.main>, ctxt=0x4d4648 <runtime.mainPC>, buf=<optimized out>)
at /home/noner/FOSS/2019ITMAN/go/src/runtime/sys_x86.go:24
...
其內容為:
func gostartcallfn(gobuf *gobuf, fv *funcval) {
var fn unsafe.Pointer
if fv != nil {
fn = unsafe.Pointer(fv.fn)
} else {
fn = unsafe.Pointer(funcPC(nilfunc))
}
gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
...
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
if buf.lr != 0 {
throw("invalid use of gostartcall")
}
buf.lr = buf.pc
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
透過判斷式中的 fv.fn 取出的成員變數實際上是 runtime.main,這個之後被賦值給 buf.pc;先前的 buf.pc 內容則是在 newproc1 函式決定。看起來的確是只有調整 buf 的內容而已。
回到 newproc1
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fn
一開始有個架構相依的清除記憶體內容函式 memclrNoHeapPointers,之所以名稱如此是因為又牽扯到 GC 機制的緣故,要使用這個函式的話,必須要我們確定要清空的部份中不含 GC 會想要處理的內容才行。
加上剛才在 gostartcall* 的內容,newg.sched 有被賦值的成員有 sp、pc(在這裡的部份後述,在 gostartcall 中被設給 lr)、g、ctxt(runtime.mainPC)。說到這個 goexit,筆者以之為關鍵字,找到了一篇簡體中文部落格清楚的讓人汗顏......。
如果直接用 vim-go 去搜的話,只會找到 stub 裡的空殼提到說這個不應該直接被呼叫,顯然也是初始化時的特殊用法之一。但是可以在 ./runtime/asm_amd64.s 裡面找到 TEXT runtime·goexit(SB),NOSPLIT,$0-0 這個函式:
// The top-most function running on a goroutine
// returns to goexit+PCQuantum.
TEXT runtime·goexit(SB),NOSPLIT,$0-0
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP
有趣的是,在 gdb 裡面沒有辦法直接找到
runtime·goexit所在的位址。那麼又要如何取得那個位置呢?筆者先是在閱讀組語時看見mov (%rbx),%rsi並推測這是fn = fv.fn運算式;然後發現這個rsi暫存器會被寫到0x40(%rax)去,那應該就是newg.sched.pc所在之處;最後按照位置推算newg.sched.lr應該就是它前一個的0x38(%rax),其中的值就是待會會展示的0x000000c00004c7d8。
註解說明,最上游(最一開始)執行在 goroutine 上的函式會回到 goexit+PCQuantum 這個位置,而這又是哪裡呢?筆者透過 gdb 去撈 newg.sched 的內容,勉強撈到
(gdb) x/10gx 0x000000c00004c7d8
0xc00004c7d8: 0x00000000004530d1 0x0000000000000000
...
(gdb) x/10gx 0x4530d0
0x4530d0 <runtime.goexit>: 0xcc90fffde2aae890 0xcccccccccccccccc
0x4530e0 <runtime.gcWriteBarrier>: 0x246c894880c48348 0x894c78246c8d4878
...
(gdb) x/10i 0x4530d0
0x4530d0 <runtime.goexit>: nop
0x4530d1 <runtime.goexit+1>: callq 0x431380 <runtime.goexit1>
0x4530d6 <runtime.goexit+6>: nop
...
也就是說,離開這些函式之後,應該會直接返回到 0x4530d1 的所在之處,呼叫 goexit1 離開或是排程。
疑問
- write barrier 的詳細定義、功能,與使用的情境。
- SIGSEGV 是什麼時候註冊好的?
- 為什麼函式名稱裡面會有特殊字元?(如
runtime·goexit)是不是這種函式就無法在 gdb 裡面定位?
本日小結
發現了類似 setjmp、longjmp 呼叫的內容,也看到執行使用者程式的準備一步一步完成了!各位讀者,我們明日再會!
第二十二天:領取號碼牌
- Day: 22
- 發佈日期: 2019-10-07
- 原文: https://ithelp.ithome.com.tw/articles/10225609
前情提要
昨日看到 gostartcallfn 函式眼睛一亮,但終究只是設定一些 context,runtime 還未結束,仍需繼續 trace。
接下去呢?
if _g_.m.curg != nil {
newg.labels = _g_.m.curg.labels
}
if isSystemGoroutine(newg, false) {
atomic.Xadd(&sched.ngsys, +1)
}
newg.gcscanvalid = false
casgstatus(newg, _Gdead, _Grunnable)
再來首先有一個判斷式,直譯的話是當前的 goroutine 的 thread 的正在執行的 goroutine,實在是不太確定為何會有此差別。使用 gdb 下去追蹤,發現:
Breakpoint 1, runtime.newproc1 (fn=0x4cf4b0 <runtime.mainPC>, argp=0x7fffffffded0 "\001", narg=0, callergp=0x558060 <runtime.g0>,
callerpc=4518057) at /usr/lib/go/src/runtime/proc.go:3323
3323 if _g_.m.curg != nil {
(gdb) p _g_
$1 = (runtime.g *) 0x558060 <runtime.g0>
(gdb) p _g_.m
$2 = (runtime.m *) 0x5585c0 <runtime.m0>
(gdb) p _g_.m.curg
$3 = (runtime.g *) 0x0
但是這個判斷式本身是什麼意思呢?找來找去,筆者在 runtime/signal_unix.go 裡面找到了端倪。這裡有個 sigfwdgo 函式,它由 signal handler 呼叫,
// Determine if the signal occurred inside Go code. We test that:
// (1) we weren't in VDSO page,
// (2) we were in a goroutine (i.e., m.curg != nil), and
// (3) we weren't in CGO.
g := sigFetchG(c)
if g != nil && g.m != nil && g.m.curg != nil && !g.m.incgo {
return false
}
給了線索的是這裡的註解。它說要測試三種狀況,但是卻有四組判斷式?第一個判斷 g 是否非空之所以能夠扯到 VDSO 機制,是因為對特定 CPU 架構來講,在 VDSO 頁面中執行 getg 函式會出問題,所以 sigFetchG 函式就會在那些情況下回傳空值給予 g;接下來的 g.m 與 g.m.curg 就是我們所關心的第二項:這代表該 signal 發生時正在執行某個 goroutine。
這是否表示我們現在的狀態不能算是正在執行某個 goroutine?也就是說,g0 算是特殊的角色?筆者預期我們在後面的執行當中,應該總會有些關於 m 和 p 的操作,屆時應該可以多得到一些線索才是。
if _g_.m.curg != nil {
newg.labels = _g_.m.curg.labels
}
從上述的 gdb 內容,我們知道這個判斷是不會進來的。其中的 labels 成員,似乎與 profiling 功能有關,這裡先行跳過。接下來是:
if isSystemGoroutine(newg, false) {
atomic.Xadd(&sched.ngsys, +1)
}
isSystemGoroutine 的語意很容易理解,它的目的是要檢查 newg 所代表的 goroutine 是否會被排除在 stack dump 與 deadlock 偵測機制之外。大致來講,被歸屬在系統 goroutine 的那些 G 的條件就是它們是隸屬於整個執行期環境,也就是 runtime. 開頭的。然而,我們現在經歷的就是其中一個例外,runtime.main 不在此範圍,所以其實這個判斷也不會進入。
接下來是,
newg.gcscanvalid = false
casgstatus(newg, _Gdead, _Grunnable)
gcscanvalid 成員的註解說明,它在開始一個 gc 巡迴的時候必須設成 false,在上一次執行 scan 之後都沒有跑過的話要設成 true。這裡 newg 才剛創始,所以當然是前者的狀況。再來我們又遇到 casgstatus 函式,這次已經將之標記為 runnable 了。
ID 設置
if _p_.goidcache == _p_.goidcacheend {
// Sched.goidgen is the last allocated id,
// this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch].
// At startup sched.goidgen=0, so main goroutine receives goid=1.
_p_.goidcache = atomic.Xadd64(&sched.goidgen, _GoidCacheBatch)
_p_.goidcache -= _GoidCacheBatch - 1
_p_.goidcacheend = _p_.goidcache + _GoidCacheBatch
}
newg.goid = int64(_p_.goidcache)
_p_.goidcache++
為什麼不能用一個 newg.goid = _p_.goidcache++ 之類的算式直接解決呢?筆者猜測這是因為,所有的 goroutine 都必須有一個獨一無二的 ID,而若是流水號機制是全域的設計的話,就無法避免多個 thread (P) 之間的同步處理。所以這裡導入了一個批次(batch)機制。既然還是沒辦法避免有個全域的流水號(sched.goidgen),那麼對每個 thread 來說,就一次多要幾個(_GoidCacheBatch)流水號回到本地端。
這也就是一開始的判斷式的由來。如果本地端可以發的號碼(idcache) 已經到底(idcacheend)了,那就只好走一次需要原子操作的全域同步流程;反之,直接將現有的配置給 newg.goid。
疑問
labels成員代表的意義?profiling 的使用方法?- 為什麼
runtime.main會有特殊的待遇,不被算在系統 goroutine 裡面?
本日小結
看到 newg 領到號碼牌了!
第二十三天:開始排隊
- Day: 23
- 發佈日期: 2019-10-08
- 原文: https://ithelp.ithome.com.tw/articles/10225785
前情提要
之前取得的 newg 狀態已經調整為可執行,而且也已經分配好 ID 了。
newproc1 的尾巴
if raceenabled {
newg.racectx = racegostart(callerpc)
}
if trace.enabled {
traceGoCreate(newg, newg.startpc)
}
runqput(_p_, newg, true)
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
wakep()
}
releasem(_g_.m)
raceenabled 之前也看過,但是這裡似乎連編譯都沒有編進去;如果要使用這個功能的話,根據 runtime/race0.go 的註解說是必須要使用 -race 編譯選項。trace.enabled 的判斷也沒有進入,看來 trace 功能也是要另外打開的,可以參考這篇文件:Go Execution Tracer。
所以接下來的 runqpunt 顯然是一大重點!傳入了稍早透過 _p_ := _g_.m.p.ptr() 取得的 P,還有新生成的 newg 還有一個布林值,我們來看看裡面是怎麼回事。
runqput
(註解說)runqput 會試著把 G 放到本地端的可執行佇列(local runnable queue)去。根據傳入的布林值(參數名為 next)真偽,有不同的處理方式:
next為否時,將傳入的 G 放到可執行佇列尾端。- 為真時,將這個 G 放到
_p_.runnext當中。我們可以在runtime/runtime2.go裡面找到這個成員變數的功能說明,算是增進排程效率的一種方法,比方說如果當前的 G 執行到等待階段而它的P.runnext裡面有個可以執行的 G,那就可以省掉一部分排程器延遲。這裡newproc1的用法屬於為真的這一項。 - 布林值不就為真或為否嗎?是沒錯,但如果可執行佇列已滿,這個操作也會沒辦法順利執行。所以只好將這個 G 放到全域執行佇列去了。
註解也說明,這個函式只能被 P 的擁有者呼叫,應該就是說不能幫其它的 P 呼叫的意思吧?回顧一下也可以驗證發現,在 newproc1 的開頭與結束分別有 acquirem 和 releasem 函式,執行到這裡為止應該算是正牌的擁有者吧。runqput 的內容如下:
func runqput(_p_ *p, gp *g, next bool) {
if randomizeScheduler && next && fastrand()%2 == 0 {
next = false
}
if next {
retryNext:
oldnext := _p_.runnext
if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
if oldnext == 0 {
return
}
// Kick the old runnext out to the regular run queue.
gp = oldnext.ptr()
}
一進入的判斷式在處理一個與之前也看過的 race 相關功能有關。由於 goroutine 的設計特性,使得 GO 語言在設計階段就謹慎考慮了並行多執行緒的執行狀況,raceenabled 所代表的 race 功能,就是要讓編譯出來的 GO binary 更能夠撞到 race condition,突顯並行的邏輯錯誤。而事實上這裡的第一個條件 randomizeScheduler 就是直接等於 raceenabled 的一個值。
由於傳入的 next 為真,接下來就會進入 retryNext 標籤以下的部份。cas 成員函式將舊值與轉型為 guintptr 的 gp 換到原本 _p_.runnext 的位置。透過 gdb 驗證的結果,我們在這個階段就成功的交換,並且發現接下來的判斷式中的 oldnext 為零而回傳了。這也是合理的,畢竟才正要開執行工作的 P 沒有道理已經擁有方便排程的快取 goroutine。
後面的流程呼應之前註解的說明,還是簡單帶過。如果是之後的執行狀況,很可能 oldnext 真的有值,那麼原本的這個 G 就應該被加到佇列去,也就是說可以和傳入的 next 為否的情況的流程共用。
retry:
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
t := _p_.runqtail
if t-h < uint32(len(_p_.runq)) {
_p_.runq[t%uint32(len(_p_.runq))].set(gp)
atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
return
}
if runqputslow(_p_, gp, h, t) {
return
}
// the queue is not full, now the put above must succeed
goto retry
接下來先取得代表執行佇列頭的 h 與 t,並可以據以判斷本地端執行佇列是否還有空間,若有就是進到第一個判斷區塊中,可見 runq 的陣列元素有個 set 成員函式可以將 gp 加入。最後一種情況就是非得將 G 加入全域執行佇列,使用 runqputslow。
回到 newproc1
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
wakep()
}
執行到這裡,用 gdb 觀察這些判斷式分別是怎麼樣的結果:
(gdb) x/20i $pc
=> 0x4313b8 <runtime.newproc1+648>: mov 0x126b92(%rip),%eax # 0x557f50 <runtime.sched+80>
0x4313be <runtime.newproc1+654>: test %eax,%eax
0x4313c0 <runtime.newproc1+656>: je 0x43143f <runtime.newproc1+783>
0x4313c2 <runtime.newproc1+658>: mov 0x126b8c(%rip),%ecx # 0x557f54 <runtime.sched+84>
0x4313c8 <runtime.newproc1+664>: test %ecx,%ecx
0x4313ca <runtime.newproc1+666>: sete %cl
0x4313cd <runtime.newproc1+669>: test %cl,%cl
0x4313cf <runtime.newproc1+671>: je 0x4313f4 <runtime.newproc1+708>
0x4313d1 <runtime.newproc1+673>: cmpb $0x0,0x141b42(%rip) # 0x572f1a <runtime.mainStarted>
0x4313d8 <runtime.newproc1+680>: je 0x4313f4 <runtime.newproc1+708>
...
這個片段中有三個 je 指令都跳到同一個地方,也就是短路的條件。最後一個最容易理解,因為 gdb 都已經逆向解析出該位址的對應標籤是 runtime.mainStarted,從理論上推導,我們目前為止還沒有任何 main 開始過的跡象,直接看這個位址也可以發現是零,所以無論如何是不會進入到判斷式區塊內部去執行 wakep 函式了。
但是前兩個呢?gdb 沒有為我們解析它們的標籤,但是搜尋一下 npidle 與 nmspinning 成員可以發現它們都是 uint32 型別且彼此相鄰(C 裡面很可能編譯器會根據情況去調整那些位置,但不知道 GO 會不會?),大致上可以當作一個佐證。位置 0x557f50 也就是推測是 sched.npidle 的值在這時候是 7,算合理因為筆者的實驗平台是 8 核心的機器,而這時候顯然已經有一個 P 正在運作了。位置 0x557f54 的內容是零,與 GC 有關,這裡就先跳過了。
最後releasem 函式結束,一路返回囉!
疑問
- tracer 的使用方法?
- GO 與 gdb 的聯動還算可用,也是 binutils 處理的轉換嗎?
本日小結
今日將 newg 推入到 P 的下一個執行的位置。雖然中途有很多步驟,但都因為我們追蹤的是第一個普通的 G 而省略掉其中大部份。
第二十四天:上膛的 goroutine
- Day: 24
- 發佈日期: 2019-10-09
- 原文: https://ithelp.ithome.com.tw/articles/10226471
前情提要
走到 newproc1 函式的結尾。新的 goroutine 已經如子彈一般上膛了。
一路返回
newproc1 回傳之後會一路回到最初的 rt0_go 去,這裡簡單回顧一下:
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newproc1(fn, argp, siz, gp, pc)
})
}
它先是回到當初在系統堆疊的空間上運行的那個無名函式(用 gdb 觀察的話會發現它的正式名稱是 runtime.newproc.func1),然後回到 newproc 函式的最後一行,然後
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// start this M
CALL runtime·mstart(SB)
回到 rt0_go。筆者這裡的環境是在 runtime/asm_amd64.s 之中。可以看見前半段的註解寫著這一段是要創造新的 goroutine 並開始程式。其中,runtime.mainPC 作為一個進入點,與代表不附帶參數的 0 一起當作參數傳入我們已經走了一個多禮拜的 newproc 函式。到此為止我們可以再回顧一下曾經在 schedinit 函式開頭看見的註解:
// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
原來我們已經經過了第三個階段,要邁向第四階段啦!
啟動這個 M
// mstart is the entry-point for new Ms.
//
// This must not split the stack because we may not even have stack
// bounds set up yet.
//
// May run during STW (because it doesn't have a P yet), so write
// barriers are not allowed.
//
//go:nosplit
//go:nowritebarrierrec
func mstart() {
註解提到這個函式是新的 M 的進入點。
附帶了之前也常常看見的
go:nosplit代表編譯器在編譯這裡的時候不可以分割 stack,但筆者實在還沒參透這個部份,也只好附在疑問一節之中。話又說回來,不能分割的原因是這時可能還沒有設定好邊界,但問題是我們之前不是處理了一些堆疊相關的內容嗎?還是那些只是給newg的,和這裡沒有關係?
func mstart() {
_g_ := getg()
osStack := _g_.stack.lo == 0
if osStack {
這裡一開始一樣是取得當前 goroutine,然後依據它的 stack 下界是否為零作為是否正在使用系統堆疊的判斷依據。這裡事實上不會進入,因此就先略過了。
使用 gdb 觀察可以理所當然的證明這時候的 goroutine 一樣是 g0 沒有變動,那麼難道 g0 不算是在使用系統堆疊嗎?
// Initialize stack guard so that we can start calling regular
// Go code.
_g_.stackguard0 = _g_.stack.lo + _StackGuard
// This is the g0, so we can also call go:systemstack
// functions, which check stackguard1.
_g_.stackguard1 = _g_.stackguard0
在 type g struct 的註解中描述 stackguard0 和 stackguard1 都是在 stack growth prologue 當中比較的對象,只是後者是給 C 使用的,前者是 GO 使用的。以上是堆疊相關的設置,之後又會深入一層。
mstart1 到 mexit
mstart1()
// Exit this thread.
switch GOOS {
case "windows", "solaris", "illumos", "plan9", "darwin", "aix":
// Windows, Solaris, illumos, Darwin, AIX and Plan 9 always system-allocate
// the stack, but put it in _g_.stack before mstart,
// so the logic above hasn't set osStack yet.
osStack = true
}
mexit(osStack)
中間的 switch-case 結構顯然沒有 Linux 的事,這裡先不管;mstart1 函式理論上會在最後一哩路作一些 M 的設置,然後就接使用者寫的 main (事實上編譯之後的正式名稱為 main.main)之後的部份。mexit 函式則反之,準備清理所使用的資源,事實上,使用 gdb 檢查 mexit 在這個 hello world 程式的呼叫狀況的話會發現,根本還來不及使用到就已經離開了:
(gdb) b runtime.mexit
Breakpoint 1 at 0x42c6a0: file /usr/lib/go/src/runtime/proc.go, line 1243.
(gdb) run
Starting program: /home/noner/FOSS/2019ITMAN/go/src/hw
[New LWP 6868]
[New LWP 6869]
[New LWP 6870]
[New LWP 6871]
Hello World!
[LWP 6871 exited]
[LWP 6870 exited]
[LWP 6869 exited]
[LWP 6868 exited]
[Inferior 1 (process 6864) exited normally]
(gdb)
而且過程中還生成了四個 thread!
疑問
go:nosplit具體來說是在哪些條件下必須要下?- 什麼才算是系統堆疊?
- stack growth prologue 具體來說是指什麼?
- 看到很多註解的部份提到必須要有 P 才能下 write barrier,他們的關聯是什麼?
本日小結
今日回到 rt0_go 再往 mstart1 出發,同時實在好奇為何 mstart1 之後會新生成這麼多個執行緒呢?
第二十五天:minit 與 signal 設置
- Day: 25
- 發佈日期: 2019-10-10
- 原文: https://ithelp.ithome.com.tw/articles/10226695
前情提要
昨日進入到 mstart 函式之中,可算是整個 bootstrap 的最後階段。在裡面走到 mstart1,也透過 gdb 觀察得知就是在這裡面執行 Hello World 無誤。
開始 mstart1
mstart1 一樣在 runtime/proc.go 裡面,最一開始是這樣:
func mstart1() {
_g_ := getg()
if _g_ != _g_.m.g0 {
throw("bad runtime·mstart")
}
先取得當前的 goroutine 之後,如果發現當前的 G 不是 g0 就強制中止啟動流程。
這麼說來,
throw函式以及後續的一連串 panic 處理也頗為值得介紹。雖然 Hello World 本身當然是不會 fail,但是可以想辦法用 gdb 輔助製造出那樣的情境。下次就來追追看吧!
下一段的程式碼開始之前是解說的註解。這裡會紀錄下前一個呼叫(caller)的狀態,包含程式執行指標(pc)和堆疊指標(sp)以及其他的資訊。這份紀錄會作為最初的堆疊(top stack),給之後的 mcall 函式使用,也用來完結那個 thread。接下來在 mstart1 呼叫到 schedule 之後就再也不會回到這個地方了,所以也可以使用這個紀錄下來的呼叫框架。
save(getcallerpc(), getcallersp())
asminit()
minit()
這個 save 函式之前應該也有看過才對。它先是透過 compiler 的一些幫忙取得這兩個傳入參數,然後在本體之中更新 sched 成員變數的諸般內容,讓日後其他執行緒執行 gogo 函式的時候使用。
還記得嗎?
gogo就是相對於gosave、類似 C 的longjmp那樣的呼叫。它的使用情境通常是gogo(&gp.sched)呼叫,代表即將加入gp到排程中。
func save(pc, sp uintptr) {
_g_ := getg()
_g_.sched.pc = pc
_g_.sched.sp = sp
_g_.sched.lr = 0
_g_.sched.ret = 0
_g_.sched.g = guintptr(unsafe.Pointer(_g_))
// We need to ensure ctxt is zero, but can't have a write
// barrier here. However, it should always already be zero.
// Assert that.
if _g_.sched.ctxt != nil {
badctxt()
}
}
總之就是一些日後會用到的設定。
asminit 和 minit
前者在 runtime/asm_amd64.s 當中,但是 amd64 架構不需要執行任何步驟就立刻回傳了,其他像是 arm、386 才有一些考量非得在這裡設定一些 CPU 相關的內容。後者是作業系統相關的部份,根據註解是用來創造一個新的 M。本體在 runtime/os_linux.go 之中,
// Called to initialize a new m (including the bootstrap m).
// Called on the new thread, cannot allocate memory.
func minit() {
minitSignals()
// for debuggers, in case cgo created the thread
getg().m.procid = uint64(gettid())
}
註解第二行說不能分配記憶體是什麼意思?裡面的 for debugger 又是什麼意思?難道不是用除錯器就毫無用處嗎?
看看對應的組語,
0x42664d <runtime.minit+29>: callq 0x43af40 <runtime.minitSignals>
0x426652 <runtime.minit+34>: callq 0x4529d0 <runtime.gettid>
0x426657 <runtime.minit+39>: mov %fs:0xfffffffffffffff8,%rax
0x426660 <runtime.minit+48>: mov 0x30(%rax),%rax
0x426664 <runtime.minit+52>: mov (%rsp),%ecx
0x426667 <runtime.minit+55>: mov %rcx,0x48(%rax)
0x42666b <runtime.minit+59>: mov 0x8(%rsp),%rbp
0x426670 <runtime.minit+64>: add $0x10,%rsp
0x426674 <runtime.minit+68>: retq
minitSignal 函式在 runtime/signal_unix.go 裡面,它會設置一個初始的 m 所需使用的 signal stack 和 mask。我們暫且跳過它只看後面的話,可以印證一些目前為止知道的事情。mov %fs:0xfffffffffffffff8,%rax 這個指令就是一直以來取得當前 goroutine 的方式,存放到 rax 暫存器之中;之後的兩次存取分別是取得當前的 M(getg().m),以及賦值給這個 M 的 procid 成員。其中我們也可以看到來自 gettid 的回傳值似乎存放在堆疊暫存器(rsp)所指的地方,雖然是先放到 4 個位元組的 ecx 裡面,再因為轉型而真正使用的是整個 8 byte 的 rcx。
這裡的 gettid 的確就是系統呼叫無誤。Linux/amd64 的這個系統呼叫 wrapper 在 runtime/sys_linux_amd64.s,
TEXT runtime·gettid(SB),NOSPLIT,$0-4
MOVL $SYS_gettid, AX
SYSCALL
MOVL AX, ret+0(FP)
RET
基本上就是 amd64 的那一套:rax 作為系統呼叫號碼(這裡的 $SYS_gettid 也定義在檔案稍早處)。
minitSignals 函式
就如前段描述的那樣,這個函式直截了當,
func minitSignals() {
minitSignalStack()
minitSignalMask()
}
前者關於佇列,我們可以再深入觀察
func minitSignalStack() {
_g_ := getg()
var st stackt
sigaltstack(nil, &st)
if st.ss_flags&_SS_DISABLE != 0 {
signalstack(&_g_.m.gsignal.stack)
_g_.m.newSigstack = true
} else {
setGsignalStack(&st, &_g_.m.goSigStack)
_g_.m.newSigstack = false
}
}
這裡的 if-else 判斷式分成 GO 語言的通常狀況以及從 cgo 來的狀況。通常狀況的話,直接把 signal 需要的堆疊設置成 gsignal 這個 goroutine 的堆疊,這也是我們這個流程當中會經過的分支。
其中有兩個呼叫非常相似,一個是判斷式之前的 sigaltstack,代表的是為了處理 signal 所需要的另一個堆疊,其本體也是一個系統呼叫的 wrapper。它的功能很有趣,前者是輸入,代表呼叫者可以指定當前 process 的 signal handler 可以使用的堆疊;後者是輸出,代表當前的 signal 所使用的堆疊。另一個是 signalstack,其實就是一個設置 signal 堆疊的函式,裡面也會引用只有使用到第一個參數的 sigaltstack 系統呼叫。
疑問
getg、getcaller*函式好像都沒有本體,所以應該是 compiler 生成的?相關的程式在哪裡呢?minit函式前得住解說不能配置記憶體是什麼意思?getg().m.procid賦值自gettid,為什麼說是為了 debugger 的?
本日小結
今日潛入 minit,看完了 signal 所需要的堆疊的配置過程。
第二十六天:signal 初始化收尾
- Day: 26
- 發佈日期: 2019-10-11
- 原文: https://ithelp.ithome.com.tw/articles/10227039
前情提要
昨日進入 minit 之後再進到 minitSignals,看完了針對 signal 使用的堆疊如何設置。
開始 minitSignalMask
func minitSignalMask() {
nmask := getg().m.sigmask
for i := range sigtable {
if !blockableSig(uint32(i)) {
sigdelset(&nmask, i)
}
}
sigprocmask(_SIG_SETMASK, &nmask, nil)
}
從當前 goroutine 所屬的 thread(M)當中取得 sigmask 成員的內容這個動作,意味著存取先前存下來的 mask。我們正在初始化的這個階段,拿到的 nmask 內容全部都是零。另一個有趣的內容是 sigtable 陣列,裡面有 GO 語言重新包裝 signal 的方法,列舉一些例子如下:
var sigtable = [...]sigTabT{
/* 0 */ {0, "SIGNONE: no trap"},
/* 1 */ {_SigNotify + _SigKill, "SIGHUP: terminal line hangup"},
/* 2 */ {_SigNotify + _SigKill, "SIGINT: interrupt"},
/* 3 */ {_SigNotify + _SigThrow, "SIGQUIT: quit"},
/* 4 */ {_SigThrow + _SigUnblock, "SIGILL: illegal instruction"},
/* 5 */ {_SigThrow + _SigUnblock, "SIGTRAP: trace trap"},
/* 6 */ {_SigNotify + _SigThrow, "SIGABRT: abort"},
...
將原本的 Unix signal 拆解、重新定義成 signal handler 處理時的參考,應該也算是一種創舉吧? blockableSig 函式的判斷內容如下:
func blockableSig(sig uint32) bool {
flags := sigtable[sig].flags
if flags&_SigUnblock != 0 {
return false
}
if isarchive || islibrary {
return true
}
return flags&(_SigKill|_SigThrow) == 0
}
也就是說,要是
flags當中包含了_SigUnblock,就立刻表明這個 signal 是不可以被 mask 阻擋的。這些 signal 都是同步的(synchrounous,跟隨當前的 執行緒一起執行卻出了狀況所發出的 signal,如上面列表的 SIGILL 與 SIGTRAP),而且以 GO 語言的處理方法來講會使之成為 panic。- 可是要是現在是作為靜態或動態函式庫被引用的話,就還是回傳為可阻擋的。這是為了給呼叫 GO 程式的 C 程式較大的決定權。
- 回傳是否不含
_SigKill或是_SigThrow。
如果是不可阻擋的那些 signal 都會進入 sigdelset 函式,將那些 signal 自 mask 中註銷掉。跑完迴圈之後,sigprocmask 將處理好的 nmask 設為所需使用的 signal mask,然後繼續。
回到 mstart1 函式
// Install signal handlers; after minit so that minit can
// prepare the thread to be able to handle the signals.
if _g_.m == &m0 {
mstartm0()
}
在當前的 goroutine 的所屬執行緒是 m0 的情況下進入 mstartm0 函式,正式啟用在此之前的 signal 處理設定,其中最關鍵的是 initsig 函式
func mstartm0() {
...
initsig(false)
}
...
func initsig(preinit bool) {
if !preinit {
// It's now OK for signal handlers to run.
signalsOK = true
}
// For c-archive/c-shared this is called by libpreinit with
// preinit == true.
if (isarchive || islibrary) && !preinit {
return
}
preinit 在這裡是一個幫助我們理解的關鍵字,代表我們是否正透過 libpreinit 函式呼叫來執行。這裡顯然不是,所以傳入的參數是否的布林值。然而,在 GO 程式被編譯成函式庫型態的時候(-buildmode=c-archive 或 -buildmode=c-shared),runtime/asm_amd64.s 中的 _rt0_amd64_lib 函式就會被作為全域的建構子被呼叫,裡面會呼叫到 initsig(true)。無論如何,這裡我們只會進入第一個判斷式,將 signalOK 設起來。
for i := uint32(0); i < _NSIG; i++ {
t := &sigtable[i]
fwdSig[i] = getsig(i)
if !sigInstallGoHandler(i) {
...
continue
}
handlingSig[i] = 1
setsig(i, funcPC(sighandler))
}
}
這個迴圈一樣會跑過所有 signal。fwdSig 是一個陣列,紀錄現在的 GO 程式控制 signal 的策略(fwd 本身是 forward 的意思);它所賦值的來源 getsig 函式會使用 sigaction 系統呼叫,取得指定的 signal 的相關設定。中段的判斷部份在處理是否要為了這個 signal 自行安裝 handler,但是現在的初始狀況完全不會進到這個部份。handlingSig 陣列用來紀錄每一個 signal 是否正在使用 GO 語言的 handler,之後的執行中有一些場合(如 disable signal)會將這個值設成 0。最後的 setsig 函式,也使用了 sigaction 設定目前為止的配置到核心裡面。
看起來會用到
sigtramp與sigreturn兩個函式。關於sighandler的詳細運作,有機會再追蹤進去。
再次回到 mstart1 函式
if fn := _g_.m.mstartfn; fn != nil {
fn()
}
if _g_.m != &m0 {
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}
schedule()
再來是如果所屬的執行緒有合法的 mstartfn 成員的話,就執行 fn 函式。下面的判斷式則是與前一段相反,必須不是系統初始執行緒才會進來作。我們現在的初始化情況,這兩個判斷都不會生效,於是直接進入 schedule 函式。
疑問
- 接收到 signal 的時候,GO 語言的處理方式是?
本日小結
今日簡單瀏覽一下最後一部份的 signal setup,看到 GO 語言如何管理不同的 signal,以及相當重視與 C 語言之間的界面。
第二十七天:goroutine 執行中
- Day: 27
- 發佈日期: 2019-10-12
- 原文: https://ithelp.ithome.com.tw/articles/10227340
前情提要
昨日加前日,將 signal 相關的機制瀏覽完,然後準備進入 schedule。
加入排程
終於來到這個無法折返點了。schedule 函式也還是一個 GO 語言函式,位在 runtime/proc.go 裡面。由於這個函式會被各個執行緒或 goroutine 頻繁地呼叫,所以裡面有各種場合的所需要的判斷,但這裡且讓我們先直衝 main:
func schedule() {
_g_ := getg()
if _g_.m.locks != 0 {
...
if _g_.m.lockedg != 0 {
...
if _g_.m.incgo {
...
...
if gp == nil {
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
if gp != nil && _g_.m.spinning {
throw("schedule: spinning with local work")
}
}
if gp == nil {
gp, inheritTime = findrunnable() // blocks until work is available
}
...
execute(gp, inheritTime)
}
中間這裡有連續三個針對 gp 為空的判斷,其實就是要尋找要拿來排程的 goroutine 的順位。雖然本地端佇列應該是最理想的候選,但是第一段會在相對比較稀有的情況下先從全域佇列取得可以執行的 goroutine;再來就是用 runqget 函式取得的、我們之前在 newproc 函式中放入的 newg;最後的話則是檢查各種可執行工作的來源,比方說全域佇列、其他 P 的工作、或是網路的 poll 工作。讓我們看看 runqget 裡面發生了什麼事:
func runqget(_p_ *p) (gp *g, inheritTime bool) {
// If there's a runnext, it's the next G to run.
for {
next := _p_.runnext
if next == 0 {
break
}
if _p_.runnext.cas(next, 0) {
return next.ptr(), true
}
}
雖然還有後半段自本地端佇列取得閒置的 G 的方式,但是現在我們會在這裡直接回傳擺到 runnext 快取區的的那一個 goroutine。在這之後的判斷式也都不會成立,直到最後執行 execute 進入。參數中的 gp 自然不需要多描述,inheritTime 是新生成的 goroutine 是否需要繼承舊有的 time slice 的布林值,這會牽涉到排程器管理它的方法。
execute 函式(一樣是在 runtime/proc.go 裡面)
註解說明:這個函式將 gp 排程到現在的 M 上面執行。inheritTime 如前段所述。這個函式不會回到 caller 那裡去,是一個比較特殊的函式。此外還有前幾日留作疑問的 write buffer 相關問題,這裡說可以允許 write buffer,因為在許多地方呼叫這個函式的時候都剛剛取得 P 而已。
func execute(gp *g, inheritTime bool) {
_g_ := getg()
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + _StackGuard
if !inheritTime {
_g_.m.p.ptr().schedtick++
}
_g_.m.curg = gp
gp.m = _g_.m
...
gogo(&gp.sched)
這裡再度有一次透過 casqstatus 的狀態更迭,從可執行變為執行中。waitsince 是一個與 block 狀態相關的估計值,這裡是初始化。preempt 成員代表是否能夠被訊號搶佔。stackguard0 成員的設置方式則與之前設置 stack 的時候相同。然後,將當前的 M(仍然是 m0)的當前 goroutine 設定成 gp,並讓 gp 的執行緒為當前的 _g_ 的。
最後這個 gogo 使用的參數 gp.sched,就是前幾天在 newporc1 函式的時候已經設好的。它本身類似 C 語言中 longjmp 的呼叫。當時已經存下的 pc,正是 runtime.main。所以這裡可以合理期待,應該會進入一些系統相依的組語片段,然後就跳轉到 runtime.main 裡面去吧。
果不其然,存取完 gobuf 型別的 gp.sched 之後,gogo 函式會陸續設置堆疊、goroutine(比方說,處理 TLS 使得之後的 getg 函式可以取得這個新的 goroutine)、以及 GC 需要的資訊,然後最後一步當然就是跳到 runtime.main 去
runtime 的 main 函式!
func main() {
g := getg()
g.m.g0.racectx = 0
if sys.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
// Allow newproc to start new Ms.
mainStarted = true
...
重複提醒一次,這時候透過
getg函式取得的g已經不是g0了。
一開始存取 g0 的 racectx 成員變數,應該是某些只有 race 編譯選項打開的時候才會用到的東西,這裡也就跳過去。sys.PtrSize 是否為 8 的判斷是為了依照 32 或 64 位元系統的差異分別設置最大的堆疊大小。接下來是全域變數 mainStarted 的設置;單就字面上的意義來講是沒什麼問題,但是註解很耐人尋味,它設置為真的結果是能夠允許 newproc 啟動新的 M?有個線索在第二十三天的內容裡面,當時我們在 newproc1 函式的後段,有個相關的複合判斷式,
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
wakep()
}
這個註解的啟動新的 M 的意思應該是,這個 wakep 函式呼叫之後,有可能在找不到閒置的 M 的情況下呼叫 newm 函式。事實上,根據 gdb 執行的狀態來看,很有可能為了 hello world 這樣的程式也產生出 4 個執行緒的過程也會包含上述路徑吧。
疑問
- 之前也曾經為此混亂過,總覺得有時候註解的文意裡面不會太區分 M、P 的概念。之後應該了解一下
wakep函式與newm函式。
本日小結
今日從 schedule 函式出發,進入到看就知道同樣很重要的 execute 函式,並且狀態變成了執行中。
第二十八天:其他的 M 登場
- Day: 28
- 發佈日期: 2019-10-13
- 原文: https://ithelp.ithome.com.tw/articles/10227625
前情提要
昨日終於進入了 runtime.main,並將全域的 mainStarted 設置為真,昭告天下執行期環境已經快要完備了。
重返 systemstack
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
systemstack(func() {
newm(sysmon, nil)
})
}
顯然我們現在的 CPU 架構並非 wasm,所以這裡就會確實進入執行在 systemstack 上的 newm 函式。它將使用 sysmon 函式當作執行的內容,且不指定 P(第二個參數的 nil)。
簡單回顧一下 systemstack 這個轉一手的過程。它在架構相依的組語檔案中,依照不同的呼叫者有不同的處理;如果是 g0 或 gsignal 進來的話,就直接呼叫傳入的函式,這也是我們之前所有呼叫的過程;但如果像現在,已經是一個普通的 goroutine 進入到這裡,就必須要讓 g0 來處理。其中還有一行註解寫道,讓執行過程看起來像是從 mstart 直接呼叫 systemstack,也就是說,除了有變換 goroutine 以執行的魔術之外,也有類似之前 gogo 那樣的技巧,根本上改變整個執行期佇列的感覺。如 gdb 所示,進到 newm 函式之時的 back trace 是這樣子:
Breakpoint 1, runtime.newm (fn={void (void)} 0x7fffffffde90, _p_=0x0) at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:1840
1840 func newm(fn func(), _p_ *p) {
(gdb) bt
#0 runtime.newm (fn={void (void)} 0x7fffffffde90, _p_=0x0) at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:1840
#1 0x0000000000450066 in runtime.main.func1 () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:134
#2 0x00000000004511a6 in runtime.systemstack () at /home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:370
#3 0x000000000042d8c0 in ?? () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:1116
#4 0x0000000000451034 in runtime.rt0_go () at /home/noner/FOSS/2019ITMAN/go/src/runtime/asm_amd64.s:220
#5 0x0000000000000000 in ?? ()
其中第 3 層雖然 symbol 和所屬檔案、位置全部都是錯的,但位置騙不了人,反組譯之後那個位置恰恰是 runtime.mstart 函式的起始位置。
無論如何,進到 newm 之後,goroutine 又會切換回 g0。
newm 函式
newm 函式使用兩個參數。第一個是 fn,將會是新生成的執行緒所要執行的函式,這裡傳入 sysmon,以下稱為系統監視者;第二個是 P 型別,但是這裡傳入的是 nil(傳入 P 是為了有時候必須被借用做記憶體配置)。
這個函式會生成一個新的 M 供接下來使用,配置的部份在第一行呼叫的 allocm 函式之中的
mp := new(m)
mp.mstartfn = fn
mcommoninit(mp)
mp.g0 = malg(8192 * sys.StackGuardMultiplier)
mp.g0.m = mp
可見除了配置這個結構體本身需要空間之外,還指配了傳進來的函式。mcommoninit 函式我們在第十天曾經看到過,總之是一些通用的配置;再來是這個新的 M 必須要有自己的基本 goroutine,使用 malg 函式配置之。newm 函式接下來進入 newm1 函式,這個命名方式大概就類似 newproc 呼叫 newproc1 的那種感覺吧。
func newm1(mp *m) {
if iscgo {
...
return
}
execLock.rlock() // Prevent process clone.
newosproc(mp)
execLock.runlock()
}
我們不在 cgo 呼叫的過程中,所以這裡先略過吧。execLock 本身是一個 rwmutex 型別的結構體,詳細的演算法無法來得及介紹,但通常它的使用就如註解說的,是為了確保 clone、execute 之類的函式不至於因為並行處理而造成系統狀態錯誤。夾在兩個 lock 方法之間的 newosproc 就是要生成系統監控者執行緒的入口了:
stk := unsafe.Pointer(mp.g0.stack.hi)
var oset sigset
sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))
sigprocmask(_SIG_SETMASK, &oset, nil)
核心的部份是被兩個 sigprocmask 函式夾在中間的 clone 函式,這兩個也都是系統呼叫的 wrapper。之所以 clone 必須被夾在中間,是不想讓創新執行緒的過程被 signal 中斷徒增紛擾。對於 clone 系統呼叫稍有經驗的讀者應該很警覺,這裡的 prototype 加了一點料,的確如此。原本的 Linux clone 長成這樣子:
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, void *newtls, pid_t *ctid */ );
這個很明顯不同於 runtime.clone 針對系統呼叫的 ABI(rdi, rsi, rdx, r10 的順序)給值:
TEXT runtime·clone(SB),NOSPLIT,$0
MOVL flags+0(FP), DI
MOVQ stk+8(FP), SI
MOVQ $0, DX
MOVQ $0, R10
// Copy mp, gp, fn off parent stack for use by child.
// Careful: Linux system call clobbers CX and R11.
MOVQ mp+16(FP), R8
MOVQ gp+24(FP), R9
MOVQ fn+32(FP), R12
MOVL $SYS_clone, AX
SYSCALL
...
這又是怎麼回事呢?其實兩個都有道理,只是 manpage 的內容是給 userspace 參考用的 API,所以 C Library 必須實作成那個樣子。以 GNU libc 為例的話,可以在這裡看到註解中寫道兩者的差別。無論如何,剛才提到的加料指的就是 M 和 G 的傳入;至於 stk 和 flags 當然是 Linux 特色的執行緒的基本需求,前者是新執行緒所需要的堆疊空間,後者則是配置新執行緒的組態設定(GO 語言都使用同一組來配置新執行緒)。
在這之後,目前的 goroutine 會正式分出另一個執行緒,
// In parent, return.
CMPQ AX, $0
JEQ 3(PC)
MOVL AX, ret+40(FP)
RET
// In child, on new stack.
MOVQ SI, SP
// If g or m are nil, skip Go-related setup.
CMPQ R8, $0 // m
JEQ nog
CMPQ R9, $0 // g
JEQ nog
...
// Call fn
CALL R12
// It shouldn't return. If it does, exit that thread.
MOVL $111, DI
MOVL $SYS_exit, AX
SYSCALL
JMP -3(PC) // keep exiting
原先的執行緒回傳去了,而新生的這個指定它可以使用的堆疊之後,終究最後會呼叫到位在 r12 暫存器的函式。但是如果是看得很任真的讀者應該會發覺不對勁,當初 newm 想要啟動的是 sysmon 函式來監控系統狀態,但是呼叫 clone 時已經狸貓換太子變成了 mstart!事實上,這是為了配置一些 M 的初始設定,而在 mstart 呼叫的 mstart1 當中,有一個區塊之前因為條件不合而沒有進入
if fn := _g_.m.mstartfn; fn != nil {
fn()
}
這時的新執行緒就會取得當時配置的系統監控者函式,因此會有這樣的 call stack:
Thread 2 "hw" hit Breakpoint 1, runtime.sysmon () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:4315
4315 func sysmon() {
(gdb) bt
#0 runtime.sysmon () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:4315
#1 0x000000000042da13 in runtime.mstart1 () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:1238
#2 0x000000000042d92e in runtime.mstart () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:1203
#3 0x0000000000455043 in runtime.clone () at /home/noner/FOSS/2019ITMAN/go/src/runtime/sys_linux_amd64.s:587
#4 0x0000000000000000 in ?? ()
(gdb)
疑問
- 之前也問過了,可是為什麼函式指標要傳程式碼的指標的指標?
- 到底為什麼會有
xxx1這種函式命名法? - GO 語言的 rwmutex 機制是什麼?
- 系統監視者函式具體來說是作什麼的?
本日小結
今日正式脫離單線程模式啦!雖然應該是沒時間深入系統監控者 M 了,但是整個 runtime 慢慢完備起來的同時,卻有可能因為 main.main 太過短暫而讓那些功能都變成殺雞的牛刀。無論如何,我們就快接近了!各位讀者,我們明日再會!
第二十九天:終點的 main.main
- Day: 29
- 發佈日期: 2019-10-14
- 原文: https://ithelp.ithome.com.tw/articles/10227829
前情提要
昨日一路單槍匹馬的執行流程 fork 出了一個 sysmon 執行緒在另外一個 M 上,正式成為多線程並行程式了。
多線程除錯的現實
我們現在有兩隻執行緒,其中一隻是原本的主執行緒,它執行完 clone 系統呼叫之後,因為都已經在各個呼叫的尾部,所以很快就沿著原路順序回到 newosproc、newm1、newm、runtime.main 去繼續執行期的設置工作;另一邊廂,新生的執行緒轉入 sysmon 函式之後,會做些什麼、走到哪裡呢?
筆者對於 gdb 的運行原理其實不太熟悉,因此不能肯定我們一路以來對 GO 程式除錯的經驗是常態或是異常;但無論如何,筆者觀察到一個現象是,當我在除錯 prompt 中鎖定一個 thread 作 step(含進入函式呼叫的下一步指令) 或 next(不含進入函式呼叫的下一步指令)的時候,其餘的執行緒都會前進的非常快速,以致於我們必須反向操作。也就是說,如果我想看主執行緒,我可以卡在系統監控者做幾次步進之後再觀察;但由於被卡住的執行緒以外的執行緒都會執行的非常快速,所以其實常常這樣操作幾次之後,主執行緒就已經走完 main.main 並結束了。
也就是說,我們這裡必須要很迂迴的去追了?也未必盡然,筆者發現 stackoverflow 上有這篇文章關於特定執行緒的恢復政策問題,似乎可以解決上述問題?這就來試試看:
(gdb) set scheduler-locking on
Target 'exec' cannot support this command.
(gdb) b runtime.sysmon
Breakpoint 1 at 0x434d10: file /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go, line 4315.
(gdb) run
Starting program: /home/noner/FOSS/2019ITMAN/go/src/hw
[New LWP 4598]
[New LWP 4599]
[New LWP 4600]
[New LWP 4601]
[Switching to LWP 4598]
Thread 2 "hw" hit Breakpoint 1, runtime.sysmon () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:4315
4315 func sysmon() {
(gdb) set scheduler-locking on
(gdb) info threads
Id Target Id Frame
1 LWP 4594 "hw" runtime.clone () at /home/noner/FOSS/2019ITMAN/go/src/runtime/sys_linux_amd64.s:556
* 2 LWP 4598 "hw" runtime.sysmon () at /home/noner/FOSS/2019ITMAN/go/src/runtime/proc.go:4315
3 LWP 4599 "hw" runtime.clone () at /home/noner/FOSS/2019ITMAN/go/src/runtime/sys_linux_amd64.s:556
4 LWP 4600 "hw" runtime.clone () at /home/noner/FOSS/2019ITMAN/go/src/runtime/sys_linux_amd64.s:556
5 LWP 4601 "hw" runtime.clone () at /home/noner/FOSS/2019ITMAN/go/src/runtime/sys_linux_amd64.s:556
各位讀者可以看到,這次的執行在卡在系統監控者所在之斷點之前,主執行緒那邊已經多 clone 出三個執行緒在等了。參考上述連結,使用 set scheduler-locking on 的排程鎖定功能,的確是可以如預期般運作的。
scheduler-locking本身還有其他功能,可參考它本身的 help 訊息去使用。
系統監控者函式內有一個巨大的迴圈且沒有離開條件。大略來說它做的事情就是 delay 一段時間,然後觀察是否要幫整個系統打雜,檢查 GC 狀態或是處理網路相關內容之類的操作。
主執行緒接下來的路
既然如此,我們就可以不在被其他 thread 干擾的情況繼續看看主執行緒的行為了。
lockOSThread()
doInit(&runtime_inittask) // must be before defer
// Defer unlock so that runtime.Goexit during init does the unlock too.
needUnlock := true
defer func() {
if needUnlock {
unlockOSThread()
}
}()
gcenable()
main_init_done = make(chan bool)
doInit(&main_inittask)
close(main_init_done)
needUnlock = false
unlockOSThread()
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
成對的內容可以先拆解分析。比方說 lockOSThread 和 unlockOSThread。鎖定是為了在初始化階段將主要 goroutine 綁定主要系統執行緒。綁定的方法也很簡單(反之亦然),它的內容是:
func lockOSThread() {
getg().m.lockedInt++
dolockOSThread()
}
...
func dolockOSThread() {
if GOARCH == "wasm" {
return // no threads on wasm yet
}
_g_ := getg()
_g_.m.lockedg.set(_g_)
_g_.lockedm.set(_g_.m)
}
值得一提的是解除綁定的呼叫出現過兩次,一次是真正的比較靠近尾端的時候,但第一次是使用 defer 呼叫的,根據註解,這可以讓它在 runtime.Goexit 執行時被呼叫。但是若是設置斷點於 unlockOSThread,會發現 Hello World 程式不會走到兩者任一。
main_init_done 是一個攜帶布林值的 channel,它也是與 C 的交接界面 cgo 機制的一部分,這裡就略過了。
doInit 是在 do 什麼 init?
上面一段引用的程式碼片段中出現了兩個 doInit 函式呼叫。一個使用參數 runtime_inittask,另一個則是 main_inittask。第一個的註解顯示它必須在 defer 使用之前呼叫。這兩個參數都屬於 initTask 型別,
type initTask struct {
// TODO: pack the first 3 fields more tightly?
state uintptr // 0 = uninitialized, 1 = in progress, 2 = done
ndeps uintptr
nfns uintptr
// followed by ndeps instances of an *initTask, one per package depended on
// followed by nfns pcs, one per init function to run
}
這個裡面就是放置一些初始化軟體包(package)需要的內容了。state 表示整個軟體包初始化階段,我們稍後進入 doInit 時會看到;ndeps 是則是其他相依的軟體包數量,至於實體則會接在 nfns 之後;nfns 成員是初始化所需要的函式數量,那些函式也會被附帶在後面。所以雖然看起來這個結構體的固定成員只有三個 uniptr,但實際上是一個不定長度結構。
處理這個結構的 doInit 方法如下:
func doInit(t *initTask) {
switch t.state {
case 2: // fully initialized
return
case 1: // initialization in progress
throw("recursive call during initialization - linker skew")
default: // not initialized yet
t.state = 1 // initialization in progress
for i := uintptr(0); i < t.ndeps; i++ {
p := add(unsafe.Pointer(t), (3+i)*sys.PtrSize)
t2 := *(**initTask)(p)
doInit(t2)
}
for i := uintptr(0); i < t.nfns; i++ {
p := add(unsafe.Pointer(t), (3+t.ndeps+i)*sys.PtrSize)
f := *(*func())(unsafe.Pointer(&p))
f()
}
t.state = 2 // initialization done
}
}
`state` 在這裡標誌著初始化的進度,完全的新軟體包為 0,還在初始化當中為 1,已經完成相依性和初始化函式執行則是完整的 2。整個 switch 結構之中還是預留了代表初始化當中的狀態的 1 的狀況,因為這時候可能表示 linker 出了點差錯。除此之外,本體還是在整個 default 的狀況裡面。
有兩個 for 迴圈分別擔任**相依性解析**與**初始函式呼叫**的工作。對於 C 母語的筆者來說,不定長度結構體的拆解沒有什麼神秘,就是指標的計算與挪移而已,這裡其實也需要一模一樣的手續,所以使用 `add` 通用函式與 `unsafe.Pointer` 操作指標。至於取得相關位置之後的後續處置,第一個負責相依性解析的迴圈是遞迴呼叫 `doInit` 本身,這其實也相當直觀;初始函式的呼叫也相當如此,就是當作間接的函式呼叫。
這個追蹤的過程也相當有趣。一開始的 `runtime` 初始有一個相依套件 `internal/bytealg`;後來的 `main` 初始就走得比較深了,雖然他本身只有 `fmt` 一個,但是後續還會因此使用到 `error`、`strconv`、`error`、`internal/reflectlite` 等等。
這些都走完了之後,我們終於進入到了最初的起點,`main.main`。
### 疑問
---
* 之前也問過了,可是為什麼函式指標要傳程式碼的指標的指標?
* 什麼叫做主要 goroutine?主要 OS thread?難道不是 `runtime.g0` 和 `runtime.m0` 嗎?
* `defer` 和 `go` 都是很常用的非同步關鍵字。它們生效的機制是什麼?(使用 gdb 已知 `go` 可能會觸發 `runtime.newproc`)
* 為什麼 `main.main` 會沒辦法被 linker 定位?還是說 GO 的連結方式有順序性,所以才強調 **when laying down the runtime**?
### 本日小結
---
介紹一個並行除錯技巧並觀察 `runtime.main` 的部份內容,並且終於來到 `main.main` 與之銜接。
第三十天:繼續前進
- Day: 30
- 發佈日期: 2019-10-15
- 原文: https://ithelp.ithome.com.tw/articles/10228328
開始
回首第一篇規劃這個系列方向時,我一股腦列出許多主題,現在看來當然是像是螞蟻要對抗巨人一般可笑。事實上,認真要追蹤那些主題的話,都可以寫成多於一整個系列的鐵人文篇幅吧。當時沒有想到這麼多,這是時間軸上由前往後、由後往前之間的差異。
最後一章名為開始,是因為後面的路還很長。筆者日常工作完全摸不到 GO 語言,此前完全只能說事業餘愛好者,這次鐵人賽雖然也算從頭到尾走過了 Hello World 的啟動,但對比已獲得的與已知不足的,這顯然也是走馬看花的小小郊遊罷了。
仍然充滿感謝。
感謝 GO 語言社群與前人的努力、
感謝親友的支持、感謝讀者的監督、
感謝一起並肩齊行的 IT 鐵人們每日發文的激勵作用 ... 而最感謝的還是 ITHome 願意主辦這樣的活動。讓有時覺得自己如同螺絲釘一般渺小的 IT 人有一個只跟自己比耐力的舞台。去年因故無法參賽,今年終於再回來了。
我對於 GO 語言仍然充滿好奇。固然,若說當今世上偉大的系統專案是巍峨的建築,C 語言仍是它們的紅磚:讓人信賴、習慣、有效率。但是進入到雲端時代的系統已經有了新的風貌:Docker、Kubernetes 等大型分散式系統及其基石,所使用的建築基本材料已經是 GO 而非 C。在語言設計上的優勢使得它編譯快速、就算與 C 語言對接也能呈現良好功能,但開發的方便性與易維護性使它在這類專案中廣為使用。
撰寫此篇的同時,GO 1.14 剛釋出,筆者日常業務的一部分的 CPU 架構 RISC-V 也進入了上游支援。GO 語言未來的延展性仍然是令人期待的。接下來列出系列文中留下的未解問題,當作之後繼續在學習 GO 語言的旅途中的指引。他們有些是關於 GO 語言的文化,有些則關於技術本身;有些系統問題龐大的可以專書探討,有些則是一些小觀念尚未打通。無論如何都是這系列中最真實的另外一面:系列文記載我所理解的部份,而這些問題代表我尚未了解的。
留下來的問題
- 所謂的
netpoll系統是指什麼?顯然在創建檔案的時候很重要。 runtime.SetFinalizer是什麼?在整個 GO 語言 runtime 中扮演何種角色?- 追蹤過程中發現搶佔是可以被關掉的,也就是說 GO 語言有非同步的搶佔引擎。其機制為何?
arg.(type)這種功能被稱作 reflect。GO 語言的 reflect 是怎麼做的?internal/race是怎麼樣的函式庫?功能?sync是怎樣的函式庫?功能?runtime.KeepAlive大致上可以顧名思義。但為什麼它出現在讀寫之後?讀寫之前難道就沒有被 runtime 影響的危險嗎?- 處理管線錯誤訊號的時候有瞄到
sigpipe,GO 語言如何處理 signal? .gopclntab區段是什麼?- 初始化後什麼時候開始進入通用的 GO 語言部份?
import關鍵字有時會引用多層結構,為什麼要這樣作?- 常常看見
internal什麼什麼。內部的這個關鍵字的差異是什麼?這些函式庫不都是內部的的嗎? unsafe的用途。sched_getaffinity並沒有像之前write那樣最終導到Syscall去。- GO 命名的歷史淵源,還有為什麼 runtime 跟大家都不一樣?是否是 linker 之類的工具鏈限制使然?
- goroutine 的構成,顯然是理解 GO 語言的關鍵。
g0和gsignal分別是怎麼來的?如何生成或指派的? - fn 函式?
- 怎麼開啟具備 race 功能的編譯模式?
skipPC的具體用途?- GO 語言抽象了所有不同架構,仍然保持
PC、SP、FP、LR等關鍵抽象暫存器,這些對於整個 stack trace 功能的具體實作為何? - goroutine 的構成,顯然是理解 GO 語言的關鍵。
g0和gsignal分別是怎麼來的?如何生成或指派的? - moduledata 有沒有別的意思?就是 symbol table 而已嗎?
- 在
heap.go裡面看到很多 heap 的管理都有強調不能使用 heap 來管理 heap,這如何作到? - 記憶體初始化的細節?
- 亂數為何需要在程序啟動的早期設置?
fastrand的用意為何? 為何選定特殊的魔術數字1597334677? - stackguard 顧名思義是 stack 的保護機制,GO 如何實作這個功能?
- GC 如何影響
m與g的運作? g和p之間的關係?- 為什麼 maps 要在
alginit之後才能用? - atomic 系列函式是如何實作的?
- 常常看到註解內有
//go:nosplit這種實際上類似給編譯器的 hint,運作機制是? - 為什麼 windows 不用相關機制來處理參數?windows 還是可以有命令列程式吧?
gostringnocopy函式裡面有一些魔幻的手法在轉換結構體與string型別變數,後面的指標機制怎麼實作?- 為什麼環境變數的陣列建構時不用
gostringnocopy? - 兩個選項一起設置的話,印出的部份會互相干擾,這難道不是 bug 嗎?
schedtrace真的有實際用途嗎?用在何處?- 為什麼
workbuf的大小綁定 2K 呢? allp的處理是看到了,那allm和allg呢?rbp在剛進入newproc函式時是 0,合理嗎?- xmm0 的操作是怎麼回事?
- 為什麼不能直接傳入
func1就好呢?這樣不是還能省一個記憶體的存取嗎? - 為什麼要傳
newproc的回傳位址給將由無名函式呼叫的newproc1呢? acquirem的註解為何是與p是否被存取有關?心理需要更好的 model 來理解這些 GO 語言的抽象物件了...acquirem和releasem的語意應該要有 atomic 的感覺,為何這裡不需要呢?GO 語言有什麼確保不會發生 race condition 的假設?- goroutine 被認為輕量的理由?
- 執行期排程器的整個運作中,一個 M 是如何面對非同步事件或是阻塞型的系統呼叫?
- 為何現在偏好的 approach 就不會有空轉?還是會有額外的 M 生成,並且在空轉找不到工作之後還是得休眠不是嗎?
gfget到底有沒有可能挨餓?- 在
gfget之中,從 gFree.stack 拿到 G 的情況下,那兩種不同的 flag 是什麼?什麼時候可以使用相關功能? malg使用new關鍵字配置所需的記憶體,相關機制為何?所取得的的記憶體應該會在 heap 上。- 關於
mcache,為何註解說是 per-P 結構,這裡卻是由 M 來提取呢? - 兩個
stackGuard分別有什麼用呢?註解中是有解釋,但是還是有點抽象。 - write barrier 的詳細定義、功能,與使用的情境。
- SIGSEGV 是什麼時候註冊好的?
- 為什麼函式名稱裡面會有特殊字元?(如
runtime·goexit)是不是這種函式就無法在 gdb 裡面定位? labels成員代表的意義?profiling 的使用方法?- 為什麼
runtime.main會有特殊的待遇,不被算在系統 goroutine 裡面? - tracer 的使用方法?
- GO 與 gdb 的聯動還算可用,也是 binutils 處理的轉換嗎?
go:nosplit具體來說是在哪些條件下必須要下?- 什麼才算是系統堆疊?
- stack growth prologue 具體來說是指什麼?
- 看到很多註解的部份提到必須要有 P 才能下 write barrier,他們的關聯是什麼?
getg、getcaller*函式好像都沒有本體,所以應該是 compiler 生成的?相關的程式在哪裡呢?minit函式前得住解說不能配置記憶體是什麼意思?getg().m.procid賦值自gettid,為什麼說是為了 debugger 的?- 接收到 signal 的時候,GO 語言的處理方式是?
- 之前也曾經為此混亂過,總覺得有時候註解的文意裡面不會太區分 M、P 的概念。之後應該了解一下
wakep函式與newm函式。 - 之前也問過了,可是為什麼函式指標要傳程式碼的指標的指標?
- 到底為什麼會有
xxx1這種函式命名法? - GO 語言的 rwmutex 機制是什麼?
- 系統監視者函式具體來說是作什麼的?
- 之前也問過了,可是為什麼函式指標要傳程式碼的指標的指標?
- 什麼叫做主要 goroutine?主要 OS thread?難道不是
runtime.g0和runtime.m0嗎? defer和go都是很常用的非同步關鍵字。它們生效的機制是什麼?(使用 gdb 已知go可能會觸發runtime.newproc)- 為什麼
main.main會沒辦法被 linker 定位?還是說 GO 的連結方式有順序性,所以才強調 when laying down the runtime?
檢討
這是第三次參賽了。有別於 2016 的跨界的追尋:trace 30個基本 Linux 系統呼叫與 2017 的與妖精共舞:在 RISC-V 架構上使用 GO 語言實作 binutils 工具包,當時的每一日就像是用競走甚至慢跑一樣的速度前進,非常疲累;這次更多的是帶著自助旅遊的心情,對於能夠理解的部份就深入一些去追蹤,搜索過資料之後無法理解的部份也不強求,相對之下是比較輕鬆的。所以一開始擬的龐大計畫看起來也無須羞赧,有些在這一趟獲得的,就會有一些在下一趟獲得。
整體來說,GO 語言的 Hello World 程式與所有其他程式一樣得從 CPU/作業系統相依的部份開始,由於內建 goroutine 這種輕量級的抽象執行單位而必需要精確控管 context,為了控管 context 又必須在仍非常早期的時候注意記憶體的狀況,比方說我們四處看到的那些編譯器選項: stack 是否可以被分割、該函式是否不能使用 write barrier 等等。之後的初始化,大多還是 G-M-P 三項之力在協調的。單就 Hello World,我們看到的還不夠多,還有許多其他的特色完全沒有觸碰到。
但那豈不是讓人更興奮嗎?我想,每一年度的鐵人賽不只是這 30 天的衝刺而已;它理所當然地包含事前充滿期待的計畫,以及事後的滿足、懊悔(XD)與不甘心等等混雜的情感,以及對於明年的自己再度興起的期待。連結起來,就是身為技術人穿越時間與自己對話、學習的過程吧。
各位讀者,祝福你們都能夠在這個系列中獲得自己想要的東西,若是有不足的部份也歡迎你們批評指教。無論如何,我們明年再見!