【ITニュース解説】Concurrency Programming (2): Language Memory Models — Rules Programmers Can Rely On
2026年09月23日に「Dev.to」が公開したITニュース「Concurrency Programming (2): Language Memory Models — Rules Programmers Can Rely On」について初心者にもわかりやすく解説しています。
ITニュース概要
並行処理では、メモリへの読み書き順序や見えるタイミングのルール(メモリモデル)が重要だ。JavaやGoは公式モデルを持つが、Pythonには統一されたモデルがないため、安全な並行プログラミングには明示的な同期ツールが不可欠である。
ITニュース解説
プログラムが複数の処理を同時に実行する並行プログラミングでは、開発者が予期せぬ結果に直面することがある。これは、書かれたコードが実際にCPUで実行される際に、コンパイラによる最適化やCPUの種類(x86-64やARM64など)によってメモリアクセスの順序が変わる可能性があるためだ。もしプログラマーが、使用する全てのコンパイラ、ランタイム、そしてCPUの特性を個別に理解しなければならないとしたら、さまざまな環境で動作する並行プログラムを作るのは極めて困難になる。
この問題を解決するために、「言語メモリモデル」という層が必要となる。言語メモリモデルは、プログラマーが信頼できる並行処理のルールを定義する。その一方で、コンパイラやランタイムは、これらのルールを各ハードウェアプラットフォームの具体的な違いを吸収しながら実装する役割を担う。JavaのJava Memory Model(JMM)やGoのGo Memory Modelなどがこれにあたる。プログラマーは、この言語が提供するメモリモデルのルールに従うことで、多様な環境下でも予測可能な並行プログラムを作成できるわけだ。
プログラマーがメモリモデルのルールを理解する上で重要な三つの側面がある。一つは「アトミック性」で、これはある操作が中断されることなく、全体として一度に実行されることを指す。二つ目は「可視性」で、あるスレッドが行った変更が、別のスレッドからいつ見えるようになるかという問題だ。三つ目は「順序付け」で、プログラム中に書かれた複数の操作が、実行時にどのような順序で現れるか、その保証に関するものだ。例えば、「カウンターを1増やす(counter++)」という操作や、「counterに1を代入し、readyをtrueにする」といった操作が、これらの側面からどのように振る舞うかを考えてみよう。
JavaのJava Memory Modelでは、まずcounter++のような単純なインクリメント操作はアトミックではないとみなされる。この操作は内部的に「カウンターの値を読み込む」「読み込んだ値に1を足す」「その結果をカウンターに書き戻す」という複数のステップからなるため、複数のスレッドが同時に実行すると、各ステップが入り混じり、最終的なカウンターの値が期待通りに2にならず1になる可能性がある。
次に可視性についてだが、あるスレッドAがcounter = 1と書き込んだ後、別のスレッドBがcounterを読み込んだとしても、Javaのメモリモデルが定める「happens-before」という関係が確立されていない限り、スレッドBが必ず1を読み込む保証はない。happens-before関係とは、ある操作が別の操作より先に起こることを保証する関係のことで、この関係が確立されていれば、先の操作の結果は後の操作から必ず見えるようになる。
順序付けの例では、スレッドAがcounter = 1の後にready = trueと設定し、スレッドBがreadyがtrueになったことを確認してからcounterを読み込んだとしても、やはり明示的なhappens-before関係がなければ、スレッドBがcounterを1として観測できる保証はない。readyがtrueになったことが見えても、その前にスレッドAが書き込んだcounter = 1がまだ見えないという状況が起こり得るのだ。
Go言語のGo Memory Modelも、Javaと同様にhappens-before関係に基づいてこれらの問題を説明する。counter++のような操作は、sync/atomicパッケージによって提供されるようなアトミックな操作ではないため、複数のゴルーチン(Goにおける並行処理単位)が同時に実行するとデータ競合(Data Race)が発生し、アトミックな更新としては扱われない。
可視性に関しても、あるゴルーチンAがcounter = 1と書き込んだ内容が、別のゴルーチンBによるcounterの読み込みで確実に観測されるためには、書き込みが読み込みのhappens-before関係にある必要がある。この関係がない場合、ゴルーチンBは必ずしも1を読み込むとは限らず、データ競合が発生する。
順序付けも同様で、ゴルーチンAがcounter = 1の後にready = trueと設定し、ゴルーチンBがreadyがtrueになったことを確認してからcounterを読み込む場合でも、Goメモリモデルでは明示的なhappens-before保証がなければ、ready = trueが観測されたとしてもcounterが1であることは保証されない。これはJavaの例と非常に似ており、どちらの言語も可視性と順序付けの保証にhappens-beforeという概念を用いている。
一方で、PythonはJavaやGoとは異なる特性を持つ。Pythonは、Java Memory ModelやGo Memory Modelのように、すべてのインタープリタ実装に共通する統一された並行性メモリモデルを定義していない。そのため、ここでは最も広く使われているCPythonというPythonインタープリタに焦点を当てる。
CPythonにおけるcounter += 1のような操作についてだが、たとえGlobal Interpreter Lock(GIL)が有効な状態であっても、この操作はアトミックではないと公式のPython FAQで明記されている。GILは、CPythonランタイムがPythonオブジェクト自体やインタープリタの内部状態を保護するためのメカニズムであり、例えばオブジェクトのリファレンスカウントが複数スレッドから同時に変更されて壊れるのを防ぐ役割を果たす。しかし、これはアプリケーションが定義する複合的な状態更新、例えばcounter += 1のような操作全体をアトミックにするものではない。
可視性や順序付けに関しても、CPythonにはJavaやGoのように統一されたhappens-beforeモデルが存在しないため、あるスレッドが書き込んだ値が別のスレッドからいつ見えるか、また操作の順序が保証されるかといったルールは、明示的に定められていない限り「未規定」と扱われる。したがって、Pythonプログラムでは、これらの保証は提供されないものと考えるべきだ。
Python 3.13以降では、GILを無効にできる「Free-threadedモード」が登場している。このモードでは複数のスレッドが並行してPythonコードを実行できるようになるが、GILがなくなったからといって、CPythonがオブジェクトやランタイム状態を保護する必要がなくなるわけではない。実際、dictやlistといった組み込み型は、並行変更から自身を保護するために内部的なロックを使用している。これはCPythonの実装の詳細であり、Python言語が統一されたメモリモデルを定義したことを意味するものではない。アプリケーションコードに対するアトミック性、可視性、順序付けに関する問題はFree-threadedモードでも依然として存在し、開発者は別途同期の仕組みを考える必要がある。GILを排除することは、グローバルなロックからよりきめ細かいロックやアトミック操作、ランタイムの協調へと同期のアプローチを移行させるものであり、この点でその実装はJavaやGoに近づいていると言える。
結論として、PythonにはJavaやGoのように、全てのインタープリタ実装に適用される統一された言語メモリモデルが存在しない。そのため、Pythonで並行処理の安全性を議論する際は、まずどのインタープリタを使用しているかを明確にし、次にPythonが提供する明示的な同期ツール(例: threading.Lockやqueue.Queue)と、それらが提供する並行性保証に依拠してプログラムを設計する必要がある。
言語メモリモデルの理解は、並行プログラムを正確かつ効率的に書く上で不可欠である。ハードウェア層での動作に加えて、言語が提供する抽象化されたルールを把握することで、開発者は複雑な並行処理の問題に対処できるようになる。次のステップでは、この言語メモリモデル層からさらに具体的な同期ツールであるミューテックスについて深く掘り下げていく。