Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 1629] ファイルの概要

このコミットは、Go言語におけるクロージャ(closures)のランタイムサポート、デバッガサポート、および関連するテストケースの追加・改善を目的としています。特に、クロージャがキャプチャした変数を適切に処理し、実行時に動的に生成されるコード(トラップリン)をデバッガやランタイムが正しく追跡できるようにするための基盤が構築されています。

コミット

commit 0f4f2a61836bba7dadb0cbdd00dfa53ba549555e
Author: Russ Cox <rsc@golang.org>
Date:   Fri Feb 6 13:46:56 2009 -0800

    closures - runtime and debugger support, test case
    
    R=r
    DELTA=257  (250 added, 1 deleted, 6 changed)
    OCL=24509
    CL=24565
---
 src/libmach_amd64/8db.c |  19 ++++++-\
 src/runtime/malloc.c    |   2 +-\
 src/runtime/mem.c       |   4 +-\
 src/runtime/rt2_amd64.c | 140 +++++++++++++++++++++++++++++++++++++++++++++++-\
 test/closure.go         |  88 ++++++++++++++++++++++++++++++\
 test/stack.go           |  14 ++++-\
 6 files changed, 260 insertions(+), 7 deletions(-)

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/0f4f2a61836bba7dadb0cbdd00dfa53ba549555e

元コミット内容

closures - runtime and debugger support, test case

R=r
DELTA=257  (250 added, 1 deleted, 6 changed)
OCL=24509
CL=24565

変更の背景

Go言語は、関数型プログラミングの要素としてクロージャをサポートしています。クロージャは、それが定義された環境(レキシカルスコープ)の変数を「キャプチャ」し、その変数を参照・変更できる関数です。この機能を実現するためには、ランタイムがクロージャの実行環境を正しく設定し、デバッガがクロージャの呼び出しスタックを正確に追跡できる必要があります。

このコミットが行われた2009年2月は、Go言語がまだ公開される前の初期開発段階でした。この時期にクロージャの基本的な機能が実装され、そのためのランタイムおよびデバッガのサポートが不可欠でした。特に、Goの初期のクロージャ実装では、キャプチャされた変数を扱うために、実行時に小さなアセンブリコード(トラップリン)を動的に生成するアプローチが取られていました。この動的コード生成には、メモリ領域に実行可能(executable)なパーミッションを与える必要があり、また、スタックトレース時にこの動的に生成されたコードを適切にスキップするロジックが必要となります。

このコミットは、Go言語の重要な機能であるクロージャを安定して動作させるための、初期かつ重要なステップの一つと言えます。

前提知識の解説

クロージャ (Closures)

クロージャとは、関数がその関数が定義された環境(レキシカルスコープ)にある変数を記憶し、その変数にアクセスできる機能を持つ関数のことです。これにより、関数が呼び出されるたびに新しい環境が作成されるのではなく、定義時の環境を保持したまま実行されるため、状態を持つ関数を作成したり、コールバック関数で特定のコンテキストを保持したりするのに役立ちます。

スタックトレース (Stack Trace)

スタックトレースとは、プログラムの実行中にエラーや特定のイベントが発生した際に、その時点までの関数呼び出しの履歴(コールスタック)を一覧表示する機能です。デバッグやエラー解析において非常に重要であり、どの関数がどの関数を呼び出し、最終的に問題が発生したのかを特定するのに役立ちます。

mmapPROT_EXEC

mmap (memory map) は、Unix系OSでファイルやデバイスをプロセスのアドレス空間にマッピングするためのシステムコールです。これにより、ファイルの内容をメモリとして直接アクセスできるようになります。 PROT_EXECmmap の引数の一つで、マッピングされたメモリ領域が実行可能であることを示します。つまり、そのメモリ領域に格納されたデータをCPUが命令として解釈し、実行することを許可します。動的にコードを生成し、それを実行するJIT (Just-In-Time) コンパイラや、Go言語の初期のクロージャ実装のように、実行時にアセンブリコードを生成する場合には、この PROT_EXEC フラグが必須となります。

AMD64 アセンブリの基本

  • ADDQ $xxx, SP: スタックポインタ SPxxx の値を加算します。これはスタック上の領域を解放する操作です。
  • SUBQ $xxx, SP: スタックポインタ SP から xxx の値を減算します。これはスタック上に領域を確保する操作です。
  • RET: 関数からリターンします。スタックからリターンアドレスをポップし、そのアドレスにジャンプします。
  • MOVQ $src, dest: 64ビットの値を src から dest へ移動します。
  • CALL fn: 関数 fn を呼び出します。現在の命令ポインタをスタックにプッシュし、fn のアドレスにジャンプします。
  • MOVSQ / REP; MOVSQ: メモリからメモリへ64ビット(Qword)のデータをコピーする命令です。REP プレフィックスが付くと、CX レジスタが示す回数だけ MOVSQ を繰り返します。これは、大量のデータを効率的にコピーする際に使用されます。

トラップリン (Trampoline) / サンク (Thunk)

トラップリンまたはサンクとは、特定の関数を呼び出す前に、追加の処理(例えば、引数の設定、環境の準備など)を行うために動的に生成される小さなコード片のことです。Goの初期のクロージャ実装では、キャプチャされた変数をスタックにコピーするなどの準備作業を行うために、このトラップリンが使用されました。

技術的詳細

このコミットの核となる技術的変更は、Goランタイムがクロージャをどのように作成し、実行し、そしてデバッガがそれらをどのように追跡するかに関するものです。

  1. 実行可能メモリの確保: src/runtime/malloc.csrc/runtime/mem.c の変更は、sys_mmap システムコールに PROT_EXEC フラグを追加しています。これは、Goランタイムが確保するメモリ領域が、読み書き可能であるだけでなく、実行可能であることを意味します。この変更は、クロージャのトラップリン(動的に生成されるアセンブリコード)がメモリ上に配置され、CPUによって実行されるために不可欠です。もし PROT_EXEC がなければ、そのメモリ領域からの命令フェッチは保護違反となり、プログラムはクラッシュします。

  2. クロージャの動的コード生成 (sys·closure): src/runtime/rt2_amd64.c に追加された sys·closure 関数は、Goにおけるクロージャの作成を担う中心的な部分です。この関数は、以下の処理を行うアセンブリコードを動的に生成し、そのコードへのポインタを返します。

    • スタックフレームの準備: SUBQ $siz, SP を使用して、キャプチャされた変数を格納するためのスタック領域を確保します。
    • キャプチャ変数のコピー: MOVQ $q, SIMOVQ SP, DI でソース(キャプチャ変数の元の場所)とデスティネーション(スタック上の新しい場所)のアドレスをそれぞれ SIDI レジスタに設定します。その後、MOVSQ または REP; MOVSQ 命令を使って、キャプチャされた変数を効率的にスタックにコピーします。これにより、クロージャ本体がこれらの変数にアクセスできるようになります。
    • クロージャ本体の呼び出し: CALL fn 命令で、実際のクロージャのロジックが記述された関数本体を呼び出します。
    • スタックのクリーンアップ: クロージャ本体の実行後、ADDQ $siz, SP を使用して、確保したスタック領域を解放します。
    • リターン: RET 命令で、クロージャの呼び出し元に戻ります。

    この動的に生成されるコードは、クロージャが呼び出されたときに、キャプチャされた変数をスタックに配置し、その後実際のクロージャ本体を実行するための「橋渡し」の役割を果たします。

  3. スタックトレースとデバッガサポート: src/libmach_amd64/8db.csrc/runtime/rt2_amd64.ci386trace および traceback/sys·Caller 関数への変更は、デバッガとランタイムのスタックトレース機能がクロージャを正しく扱えるようにするためのものです。

    • これらの関数は、スタックを遡って呼び出し履歴を解析する際に、クロージャのトラップリンによって生成された ADDQ $xxx, SP; RET という命令シーケンスを検出するロジックが追加されています。
    • このシーケンスを検出した場合、それはクロージャのトラップリンの終わりを示しているため、スタックポインタとプログラムカウンタを適切に調整し、トラップリンをスキップして、そのクロージャを呼び出した真の呼び出し元を特定できるようにします。これにより、デバッガがクロージャを介した呼び出しチェーンを正確に表示できるようになります。

コアとなるコードの変更箇所

src/runtime/rt2_amd64.csys·closure 関数

// func closure(siz int32,
//	fn func(arg0, arg1, arg2 *ptr, callerpc uintptr, xxx) yyy,
//	arg0, arg1, arg2 *ptr) (func(xxx) yyy)
void
sys·closure(int32 siz, byte *fn, byte *arg0)
{
	byte *p, *q, **ret;
	int32 i, n;
	int64 pcrel;

	if(siz < 0 || siz%8 != 0)
		throw("bad closure size");

	ret = (byte**)((byte*)&arg0 + siz);

	if(siz > 100) {
		// TODO(rsc): implement stack growth preamble?
		throw("closure too big");
	}

	// compute size of new fn.
	// must match code laid out below.
	n = 7+10+3;	// SUBQ MOVQ MOVQ
	if(siz <= 4*8)
		n += 2*siz/8;	// MOVSQ MOVSQ...
	else
		n += 7+3;	// MOVQ REP MOVSQ
	n += 12;	// CALL worst case; sometimes only 5
	n += 7+1;	// ADDQ RET

	// store args aligned after code, so gc can find them.
	n += siz;
	if(n%8)
		n += 8 - n%8;

	p = mal(n);
	*ret = p;
	q = p + n - siz;
	mcpy(q, (byte*)&arg0, siz);

	// SUBQ $siz, SP
	*p++ = 0x48;
	*p++ = 0x81;
	*p++ = 0xec;
	*(uint32*)p = siz;
	p += 4;

	// MOVQ $q, SI
	*p++ = 0x48;
	*p++ = 0xbe;
	*(byte**)p = q;
	p += 8;

	// MOVQ SP, DI
	*p++ = 0x48;
	*p++ = 0x89;
	*p++ = 0xe7;

	if(siz <= 4*8) {
		for(i=0; i<siz; i+=8) {
			// MOVSQ
			*p++ = 0x48;
			*p++ = 0xa5;
		}
	} else {
		// MOVQ $(siz/8), CX  [32-bit immediate siz/8]
		*p++ = 0x48;
		*p++ = 0xc7;
		*p++ = 0xc1;
		*(uint32*)p = siz/8;
		p += 4;

		// REP; MOVSQ
		*p++ = 0xf3;
		*p++ = 0x48;
		*p++ = 0xa5;
	}


	// call fn
	pcrel = fn - (p+5);
	if((int32)pcrel == pcrel) {
		// can use direct call with pc-relative offset
		// CALL fn
		*p++ = 0xe8;
		*(int32*)p = pcrel;
		p += 4;
	} else {
		// MOVQ $fn, CX  [64-bit immediate fn]
		*p++ = 0x48;
		*p++ = 0xb9;
		*(byte**)p = fn;
		p += 8;

		// CALL *CX
		*p++ = 0xff;
		*p++ = 0xd1;
	}

	// ADDQ $siz, SP
	*p++ = 0x48;
	*p++ = 0x81;
	*p++ = 0xc4;
	*(uint32*)p = siz;
	p += 4;

	// RET
	*p++ = 0xc3;

	if(p > q)
		throw("bad math in sys.closure");
}

コアとなるコードの解説

sys·closure 関数は、Go言語のクロージャをサポートするために、実行時にアセンブリコードを生成する役割を担っています。

  1. 引数と初期チェック:

    • siz: クロージャがキャプチャする変数の合計サイズ(バイト単位)。
    • fn: クロージャの実際のロジックを含む関数本体へのポインタ。
    • arg0: キャプチャされた変数の最初のものへのポインタ。
    • siz が負の値であったり、8の倍数でなかったりするとエラーをスローします。これは、AMD64アーキテクチャでのアラインメント要件や、ポインタが8バイト単位で扱われることに起因します。
    • siz が100バイトを超える場合もエラーをスローします。これは、初期実装における制限、またはスタック成長のプレアンブル(前処理)がまだ実装されていないためと考えられます。
  2. メモリ確保とデータコピー:

    • n は、生成されるアセンブリコードと、その後に配置されるキャプチャされた変数の合計サイズを計算します。
    • p = mal(n): mal 関数(最終的には sys_mmap を呼び出す)を使って、n バイトのメモリを確保します。このメモリは、前述の通り PROT_EXEC フラグ付きで確保されるため、実行可能です。
    • *ret = p: 生成されたコードの開始アドレスを、クロージャの呼び出し元に返すためのポインタに設定します。
    • q = p + n - siz: キャプチャされた変数がメモリ上で配置される開始アドレスを計算します。これは、生成されたアセンブリコードの直後に配置されます。
    • mcpy(q, (byte*)&arg0, siz): arg0 から始まるキャプチャされた変数を、新しく確保したメモリ領域の q の位置にコピーします。
  3. アセンブリコードの動的生成: p ポインタをインクリメントしながら、AMD64のアセンブリ命令のバイト列をメモリに書き込んでいきます。

    • スタック領域の確保:

      SUBQ $siz, SP  ; SPからsizを減算し、スタック上にsizバイトの領域を確保
      

      これは、キャプチャされた変数をスタックにコピーするためのスペースを確保します。

    • レジスタの設定:

      MOVQ $q, SI    ; キャプチャ変数のソースアドレス(q)をSIレジスタにロード
      MOVQ SP, DI    ; スタック上のデスティネーションアドレス(SP)をDIレジスタにロード
      

      SI (Source Index) と DI (Destination Index) レジスタは、メモリコピー命令 (MOVSQ) で使用されます。

    • キャプチャ変数のコピー: siz の値に応じて、2つの異なるコピー方法が選択されます。

      • siz <= 4*8 (32バイト以下) の場合: 複数の MOVSQ 命令を直接書き込みます。MOVSQSI から DI へ8バイトをコピーし、両方のレジスタをインクリメントします。
      • siz > 4*8 の場合: REP; MOVSQ を使用します。これは、CX レジスタにコピー回数(siz/8)を設定し、REP プレフィックスを付けて MOVSQ を実行することで、ループで効率的にコピーを行います。
    • クロージャ本体の呼び出し:

      CALL fn        ; クロージャの実際の関数本体を呼び出す
      

      fn への呼び出しは、PC相対アドレス指定(pcrelint32 に収まる場合)または絶対アドレス指定(MOVQ $fn, CX; CALL *CX)のいずれかで行われます。

    • スタック領域の解放:

      ADDQ $siz, SP  ; SPにsizを加算し、スタック領域を解放
      

      クロージャ本体の実行後、確保したスタック領域をクリーンアップします。

    • リターン:

      RET            ; 呼び出し元に戻る
      

この sys·closure 関数によって生成されたアセンブリコードは、Goのクロージャが呼び出されたときに実行される「トラップリン」として機能します。このトラップリンが、キャプチャされた変数を適切にスタックに配置し、クロージャ本体に制御を渡し、その後スタックをクリーンアップすることで、クロージャのセマンティクスを実現しています。

関連リンク

参考にした情報源リンク

  • mmap システムコールと PROT_EXEC フラグに関する一般的な情報:
  • AMD64アセンブリ命令に関する情報:
  • Go言語の初期のクロージャ実装に関する議論や資料(もし公開されているものがあれば):
    • このコミットはGo公開前の非常に初期のものであるため、当時の詳細な設計ドキュメントが一般に公開されている可能性は低いですが、Goの歴史に関する記事やGo開発者のブログなどが参考になる場合があります。
    • The Go Programming Language Blog (Goの進化に関する公式ブログ)