Czym jest język zarządzany (managed language)?
Czym jest język zarządzany (managed language)?
Język zarządzany to, w przeciwieństwie do języka niezarządzanego – czyli takiego, w którym wykonanie kodu nie odbiega znacząco od logiki zaimplementowanej przez programistę – język, który realizuje zadania takie jak GC, optymalizacja runtime, green threads czy obsługa współbieżności na poziomie runtime, dzięki czemu użytkownik nie musi zajmować się ryzykownym zarządzaniem niskopoziomowym. W przypadku takich języków zaletą jest możliwość skupienia się wyłącznie na logice biznesowej i pełne zaangażowanie w proces programowania, jednak z drugiej strony, ponieważ program może działać inaczej, niż podpowiada intuicja programisty, niekiedy wymagane jest zaawansowane strojenie runtime. W pierwszej kolejności przyjrzymy się językowi Go, który spośród języków zarządzanych najwierniej realizuje filozofię minimalizmu, a jego asembler jest niezwykle przejrzysty.
Struktura binarna języka Go
| .text | .data | .gopclntab, .typelink itp. |
|---|---|---|
| Kod maszynowy do wykonania | Dane do zapisania | Sekcje runtime języka |
| Ponieważ język Go nie tłumaczy kodu 1:1 na język maszynowy w sposób bezpośredni, logika w sekcji .text jest ściśle powiązana z sekcjami runtime języka. | ||
| Ponadto w asemblerze sekcji .text umieszczane są funkcje, takie jak runtime.printnl(), których użytkownik nie zaimplementował samodzielnie. | ||
| Dzięki takiemu automatycznemu wstrzykiwaniu kodu język Go pomaga odciążyć programistę od ręcznego zarządzania zasobami. |
Analiza funkcji main w języku Go
Najpierw napiszmy prosty przykład kodu źródłowego main.go i przyjrzyjmy się mu od funkcji main na maszynie AMD64.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Następnie budujemy go w następujący sposób:
1go build main.go
2
Go wspiera go tool, co ułatwia debugowanie niskopoziomowe. Aby w go tool wyświetlić asembler wyłącznie dla funkcji main w pakiecie main, wprowadzamy następującą komendę:
1go tool objdump -s "main\.main" ./main
2
Asembler
1TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
2 main.go:7 0x468f60 493b6610 CMPQ SP, 0x10(R14)
3 main.go:7 0x468f64 762f JBE 0x468f95
4 main.go:7 0x468f66 55 PUSHQ BP
5 main.go:7 0x468f67 4889e5 MOVQ SP, BP
6 main.go:7 0x468f6a 4883ec10 SUBQ $0x10, SP
7 main.go:8 0x468f6e 90 NOPL
8 main.go:4 0x468f6f e8cca3fcff CALL runtime.printlock(SB)
9 main.go:4 0x468f74 488d05da290100 LEAQ 0x129da(IP), AX
10 main.go:4 0x468f7b bb0b000000 MOVL $0xb, BX
11 main.go:4 0x468f80 e83bacfcff CALL runtime.printstring(SB)
12 main.go:4 0x468f85 e8f6a5fcff CALL runtime.printnl(SB)
13 main.go:4 0x468f8a e811a4fcff CALL runtime.printunlock(SB)
14 main.go:9 0x468f8f 4883c410 ADDQ $0x10, SP
15 main.go:9 0x468f93 5d POPQ BP
16 main.go:9 0x468f94 c3 RET
17 main.go:7 0x468f95 e8e6afffff CALL runtime.morestack_noctxt.abi0(SB)
18 main.go:7 0x468f9a ebc4 JMP main.main(SB)
19
- Sprawdzenie, czy bieżąca przestrzeń ramki stosu goroutine jest wystarczająca poprzez porównanie (CMPQ) wartości stack guard wewnątrz rejestru bloku kontrolnego goroutine (R14) z bieżącym wskaźnikiem stosu (SP); jeśli jest niewystarczająca, następuje skok (JBE) pod adres 0x468f95, będący punktem wejścia do rozszerzenia stosu.
- Zapisanie poprzedniego base pointera na stosie za pomocą PUSHQ BP.
- Skopiowanie bieżącego wskaźnika stosu (SP) do rejestru base pointer (BP) w celu ustalenia punktu odniesienia stosu na początku funkcji.
- Następnie przydzielenie 16 bajtów przestrzeni stosu na zmienne lokalne (SUBQ $0x10, SP) i wypełnienie wirtualnych instrukcji za pomocą NOPL w celu wyrównania pamięci podręcznej (cache alignment) procesora.
- Wywołanie runtime.printlock(SB) w celu zsynchronizowania standardowego wyjścia ciągu znaków wewnątrz runtime języka Go.
- Użycie instrukcji LEAQ do zapisania adresu początkowego ciągu znaków przydzielonego jako stała ("Hello World") w rejestrze ogólnego przeznaczenia AX, który zgodnie ze specyfikacją Go ABI służy jako pierwszy parametr.
- Następnie zapisanie wartości reprezentującej długość ciągu znaków w drugim rejestrze parametrów BX (MOVL $0xb, BX, czyli 11 w systemie dziesiętnym).
- Wywołanie runtime.printstring(SB), które wypisuje dane na konsolę na podstawie przekazanych informacji w AX (adres danych) i BX (długość).
- Wywołanie runtime.printnl(SB) w celu obsługi znaku nowej linii.
- Po zakończeniu operacji wyjścia, zwolnienie blokady poprzez runtime.printunlock(SB).
- Przywrócenie przydzielonych 16 bajtów pamięci stosu za pomocą ADDQ $0x10, SP.
- Przywrócenie poprzedniego base pointera za pomocą POPQ BP.
- Zwrócenie kontroli do miejsca wywołania funkcji poprzez RET.
- Jeśli przy początkowym sprawdzeniu stosu okazało się, że miejsca jest za mało, wywoływana jest funkcja runtime.morestack_noctxt.abi0(SB) pod adresem 0x468f95, aby dynamicznie rozszerzyć stos runtime, co jest charakterystyczne dla języka zarządzanego.
- Po zakończeniu rozszerzania stosu następuje powrót (JMP) do punktu wejścia main.main(SB). Jak widać, asembler logiki biznesowej jest dość przejrzysty i został wzbogacony jedynie o lekkie zarządzanie przez runtime.
W przypadku braku optymalizacji
Powyższa postać jest wynikiem automatycznego zoptymalizowania dwóch osobnych funkcji przez kompilator Go poprzez inlining. Jednak w celach edukacyjnych założymy, że w tym przypadku sayHello nie będzie poddawane inliningowi. Aby to osiągnąć, kompilujemy źródło z następującą flagą:
1go build -gcflags="-l" main.go
2
Jeśli wyświetlimy wyniki w powłoce, zauważymy powielony asembler.
1yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
2
3go tool objdump -s "main\.sayHello" ./main
4TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
5 main.go:3 0x468f60 493b6610 CMPQ SP, 0x10(R14)
6 main.go:3 0x468f64 7636 JBE 0x468f9c
7 main.go:3 0x468f66 55 PUSHQ BP
8 main.go:3 0x468f67 4889e5 MOVQ SP, BP
9 main.go:3 0x468f6a 4883ec10 SUBQ $0x10, SP
10 main.go:5 0x468f6e 4889442420 MOVQ AX, 0x20(SP)
11 main.go:5 0x468f73 48895c2428 MOVQ BX, 0x28(SP)
12 main.go:4 0x468f78 e8c3a3fcff CALL runtime.printlock(SB)
13 main.go:4 0x468f7d 488b442420 MOVQ 0x20(SP), AX
14 main.go:4 0x468f82 488b5c2428 MOVQ 0x28(SP), BX
15 main.go:4 0x468f87 e834acfcff CALL runtime.printstring(SB)
16 main.go:4 0x468f8c e8efa5fcff CALL runtime.printnl(SB)
17 main.go:4 0x468f91 e80aa4fcff CALL runtime.printunlock(SB)
18 main.go:5 0x468f96 4883c410 ADDQ $0x10, SP
19 main.go:5 0x468f9a 5d POPQ BP
20 main.go:5 0x468f9b c3 RET
21 main.go:3 0x468f9c 4889442408 MOVQ AX, 0x8(SP)
22 main.go:3 0x468fa1 48895c2410 MOVQ BX, 0x10(SP)
23 main.go:3 0x468fa6 e8d5afffff CALL runtime.morestack_noctxt.abi0(SB)
24 main.go:3 0x468fab 488b442408 MOVQ 0x8(SP), AX
25 main.go:3 0x468fb0 488b5c2410 MOVQ 0x10(SP), BX
26 main.go:3 0x468fb5 eba9 JMP main.sayHello(SB)
27
Po wyłączeniu inliningu wstrzykiwane są operacje MOVQ, które ponownie zapisują wartości pod offset wskaźnika stosu, taki jak 0x20(SP), w celu zachowania parametrów (AX, BX) zgodnie ze specyfikacją wywołania funkcji. Oznacza to, że optymalizacje kompilatora dotyczą właśnie eliminacji takich zbędnych operacji przenoszenia w pamięci oraz narzutu związanego z wywołaniami.
Następnym razem
W kolejnej części omówimy instrukcje if oraz switch w języku Go. Jeśli czas pozwoli, w przyszłości przeanalizujemy również sekcje runtime języka Go.