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

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

このコミットは、Go言語のコマンドラインツール cmd/go におけるデータ競合(data race)の修正に関するものです。具体的には、buildLdflags というスライスに対する並行なappend操作によって発生するデータ競合を解消することを目的としています。

コミット

commit d1c6c6004be6d2c3ad030c5c1ef5ae1c84c7d293
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Tue Mar 4 11:42:02 2014 +0400

    cmd/go: fix data race on buildLdflags
    Fixes #7438.
    
    LGTM=rsc
    R=golang-codereviews
    CC=bradfitz, golang-codereviews, iant, rsc
    https://golang.org/cl/70420044

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

https://github.com/golang/go/commit/d1c6c6004be6d2c3ad030c5c1ef5ae1c84c7d293

元コミット内容

cmd/go: fix data race on buildLdflags
Fixes #7438.

LGTM=rsc
R=golang-codereviews
CC=bradfitz, golang-codereviews, iant, rsc
https://golang.org/cl/70420044

変更の背景

このコミットは、Go Issue #7438「cmd/go: data race on buildLdflags」を修正するために行われました。cmd/go ツールは、Goプログラムのビルド、テスト、インストールなどを行うための主要なコマンドラインインターフェースです。ビルドプロセス中、リンカに渡すフラグ(ldflags)を構築する際に、buildLdflags というグローバルな(または複数のゴルーチンからアクセスされうる)スライスが使用されていました。

問題は、複数のビルドプロセスや並行して実行される操作が同じ buildLdflags スライスに対して append 操作を行う可能性があったことです。Goのスライスは、その実体が配列へのポインタ、長さ(len)、容量(cap)を持つ構造体です。append 操作は、スライスの容量が不足した場合、より大きな新しい基底配列を割り当て、既存の要素をコピーし、新しい要素を追加するという一連の操作を行います。

複数のゴルーチンが同時に buildLdflags に対して append を実行し、かつ容量の再割り当てが必要な状況に陥ると、以下のいずれかのデータ競合が発生する可能性がありました。

  1. 基底配列の再割り当てとコピーの競合: 複数のゴルーチンが同時に新しい基底配列を割り当てようとし、古い配列からの要素のコピーが競合する。これにより、不正なデータがコピーされたり、メモリが破損したりする可能性があります。
  2. スライスヘッダの更新の競合: lencap といったスライスヘッダのフィールドが複数のゴルーチンによって同時に更新され、最終的に不正なスライスヘッダの状態になる可能性があります。

このようなデータ競合は、予測不能なビルドエラー、クラッシュ、または誤ったリンカフラグの生成につながるため、修正が必要でした。

前提知識の解説

1. データ競合 (Data Race)

データ競合は、並行プログラミングにおける一般的なバグの一種です。以下の3つの条件がすべて満たされたときに発生します。

  • 少なくとも2つのゴルーチン(またはスレッド)が同じメモリ位置にアクセスする。
  • それらのアクセスの少なくとも1つが書き込み操作である。
  • それらのアクセスが同期メカニズム(ミューテックス、チャネルなど)によって保護されていない。

データ競合が発生すると、プログラムの動作は予測不能になります。例えば、あるゴルーチンが値を書き込んでいる最中に別のゴルーチンがその値を読み取ると、部分的に更新された値や古い値を読み取ってしまう可能性があります。Go言語にはデータ競合を検出するためのツール(go run -race)が用意されています。

2. Goのスライス (Slice)

Goのスライスは、配列のセグメントを参照する軽量なデータ構造です。スライスは以下の3つの要素で構成されます。

  • ポインタ (Pointer): スライスが参照する基底配列の最初の要素へのポインタ。
  • 長さ (Length): スライスに含まれる要素の数。len(s) で取得できます。
  • 容量 (Capacity): スライスの基底配列のサイズ。スライスの最初の要素から基底配列の末尾までの要素数。cap(s) で取得できます。

スライスは動的にサイズを変更できるかのように見えますが、実際には append 操作によって容量が不足した場合、Goランタイムはより大きな新しい基底配列を内部的に割り当て、既存の要素を新しい配列にコピーし、スライスヘッダ(ポインタ、長さ、容量)を更新します。この「新しい配列の割り当てとコピー」のプロセスが、複数のゴルーチンから同時に行われるとデータ競合の原因となります。

3. ldflags (Linker Flags)

ldflags は、Goコンパイラが生成したオブジェクトファイルをリンクする際に、Goリンカ(cmd/link)に渡されるコマンドラインフラグのことです。これらは、実行可能ファイルのビルドに関する様々な設定を行うために使用されます。一般的な用途としては、以下のようなものがあります。

  • バージョン情報の埋め込み: go build -ldflags "-X main.version=1.0.0" のように、プログラムのバージョン情報をバイナリに埋め込むことができます。
  • ビルド時間の埋め込み: 同様にビルド日時などを埋め込むことも可能です。
  • デバッグ情報の制御: デバッグ情報の有無や形式を制御します。
  • シンボルテーブルの操作: シンボルテーブルの削除など、バイナリサイズを最適化するための操作。

cmd/go ツールは、これらの ldflags を内部的に構築し、リンカに渡します。

技術的詳細

このコミットの技術的な核心は、Goのスライスの append 操作が持つ特性と、それが並行環境でどのようにデータ競合を引き起こすか、そしてその解決策としてのスライスの再スライス(re-slicing)にあります。

buildLdflags は、リンカフラグを保持するための []string 型のスライスです。gcToolchain.ld 関数内で、このスライスに -installsuffix などの追加フラグが append されます。

データ競合が発生するシナリオは以下の通りです。

  1. 複数のゴルーチン(例えば、複数のパッケージを並行してビルドしている場合)が同時に gcToolchain.ld 関数を実行する。
  2. 各ゴルーチンは ldflags := buildLdflags という行で buildLdflags のコピーを作成します。ここで重要なのは、スライスは参照型であるため、このコピーは buildLdflags同じ基底配列を共有しているという点です。つまり、ldflagsbuildLdflags は異なるスライスヘッダを持つかもしれませんが、そのポインタは同じ基底配列を指しています。
  3. その後、各ゴルーチンは ldflags に対して append 操作を行います。
  4. もし ldflags(そして buildLdflags)の容量が不足している場合、append 操作は新しい、より大きな基底配列を割り当て、既存の要素をコピーし、スライスヘッダを更新します。
  5. この「新しい基底配列の割り当てとコピー」のプロセスが、複数のゴルーチンによって同時に行われると、データ競合が発生します。例えば、ゴルーチンAが新しい配列にコピーしている最中に、ゴルーチンBも同じ古い配列からコピーしようとしたり、あるいはゴルーチンAが新しい配列へのポインタを更新した直後にゴルーチンBが古いポインタを基に操作しようとしたりする可能性があります。

このコミットの修正は、ldflags = ldflags[:len(ldflags):len(ldflags)] という行を追加することで、このデータ競合を解消します。

このスライス操作の目的は、ldflags スライスの容量を現在の長さと同じに制限することです。

  • ldflags[:len(ldflags)] は、スライスの長さと同じ長さを持つ新しいスライスを作成します。この時点では、容量は元のスライスと同じです。
  • ldflags[:len(ldflags):len(ldflags)] は、さらに容量も現在の長さと同じに制限します。

この操作の結果、ldflagsbuildLdflags とは異なる基底配列を参照するようになります(もし元の buildLdflags の容量が長さより大きかった場合)。具体的には、ldflags の容量がその長さと等しくなるため、その後の append 操作は必ず新しい基底配列を割り当てることになります。

これにより、各ゴルーチンが ldflags に対して append を実行する際に、それぞれが独立した新しい基底配列を割り当てて操作するようになります。もはや複数のゴルーチンが同じ buildLdflags の基底配列を共有して append を試みることはなくなり、データ競合が解消されます。

この修正は、buildLdflags がグローバル変数であるか、あるいは複数のゴルーチンからアクセスされうる共有リソースであるという前提に基づいています。各ゴルーチンが buildLdflags のコピーを受け取った後、そのコピーが元の buildLdflags の基底配列とは独立したメモリ領域を指すように強制することで、並行な append 操作が安全に行われるようになります。

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

diff --git a/src/cmd/go/build.go b/src/cmd/go/build.go
index 6c9b9f7e50..bf30be70e4 100644
--- a/src/cmd/go/build.go
+++ b/src/cmd/go/build.go
@@ -1714,6 +1714,8 @@ func (gcToolchain) ld(b *builder, p *Package, out string, allactions []*action,\
 		}\n \t}\n \tldflags := buildLdflags
+\t// Limit slice capacity so that concurrent appends do not race on the shared array.
+\tldflags = ldflags[:len(ldflags):len(ldflags)]
 \tif buildContext.InstallSuffix != \"\" {\n \t\tldflags = append(ldflags, \"-installsuffix\", buildContext.InstallSuffix)\n \t}\n```

## コアとなるコードの解説

変更は `src/cmd/go/build.go` ファイルの `gcToolchain.ld` 関数内で行われています。

元のコードでは、以下の行で `buildLdflags` スライスのコピーが `ldflags` に代入されていました。

```go
ldflags := buildLdflags

この時点では、ldflagsbuildLdflags と同じ基底配列を共有しています。その後の append 操作(例: ldflags = append(ldflags, "-installsuffix", buildContext.InstallSuffix))で、もし基底配列の容量が不足した場合、Goランタイムは新しい配列を割り当て、要素をコピーし、スライスヘッダを更新します。この一連の操作が、複数のゴルーチンから同時に行われるとデータ競合が発生します。

追加された行は以下の通りです。

// Limit slice capacity so that concurrent appends do not race on the shared array.
ldflags = ldflags[:len(ldflags):len(ldflags)]

この行は、ldflags スライスの容量を現在の長さと同じに制限する「フルスライス式」です。

  • ldflags は元のスライスです。
  • len(ldflags) は現在のスライスの長さです。
  • cap(ldflags) は現在のスライスの容量です。

s[low : high : max] という形式のスライス式は、low から high-1 までの要素を含む新しいスライスを作成し、その容量を max - low に設定します。

この場合、low0highlen(ldflags)maxlen(ldflags) です。 したがって、新しい ldflags スライスは、元の ldflags と同じ要素を持ち、長さも同じですが、容量もその長さと同じになります。

この操作の重要な効果は、もし元の ldflags(つまり buildLdflags)がその長さよりも大きな容量を持っていた場合、この再スライスによって新しい基底配列が割り当てられ、元の要素がそこにコピーされることです。これにより、ldflagsbuildLdflags とは異なる独立した基底配列を参照するようになります。

結果として、この行の後に ldflags に対して append 操作が行われると、ldflags の容量は常に不足している状態(長さと容量が同じため)から始まるため、append は必ず新しい基底配列を割り当てて要素を追加します。これにより、各ゴルーチンがそれぞれ独立したメモリ領域で append 操作を行うことが保証され、共有された buildLdflags の基底配列に対するデータ競合が解消されます。

関連リンク

参考にした情報源リンク