【Python】マルチプロセスの深淵:なぜ `AttributeError: Can’t get attribute` は起きるのか?Spawn/Forkの挙動からPickleの内部構造まで徹底解剖

Python

「あのエラー」について、どこよりも深く、そして執拗に解説していこうと思います。

今日取り上げるのは、Pythonの並列処理、特に multiprocessing モジュールを使い始めた初心者が必ずと言っていいほどぶち当たる壁、「AttributeError: Can’t get attribute ‘xxx’ on です。このエラー、一見すると単なるパスの通し忘れやスペルミスに見えますが、その裏側にはPythonのインタープリタの設計、OSのプロセス生成モデル、そして「Pickle」というシリアライズ機構の深遠な仕様が隠されています。

この記事を読み終える頃には、あなたはこのエラーを解決できるだけでなく、PythonがどのようにOSと対話し、メモリを管理しているのかという「コンピュータ・サイエンス」の核心に触れることになるでしょう。

1. 現象の確認:何が起きているのか?

まずは、エラーが発生する典型的なコードを見てみましょう。特にWindowsユーザーや、最近のmacOSでPython 3.8以降を使っているエンジニアが遭遇しやすいパターンです。

import multiprocessing

def worker_task(data):
    return f"Processed {data}"

# ここでエラーが発生する可能性が高い
p = multiprocessing.Process(target=worker_task, args=("Hello",))
p.start()
p.join()

一見、何の問題もないコードに見えます。しかし、これを実行すると、非情にも次のようなスタックトレースが吐き出されます。

AttributeError: Can't get attribute 'worker_task' on <module '__main__' (built-in)>

「worker_taskなんて属性は __main__ にはないよ」とPythonは言っています。しかし、コードを見れば明らかに定義されています。なぜPythonは見つけられないのでしょうか?この謎を解く鍵は、「プロセスの開始方式(Start Methods)」にあります。

2. Pythonマルチプロセスの二大巨頭:Fork vs Spawn

Pythonの multiprocessing モジュールは、物理的に異なるCPUコアを活用するために、新しいPythonインタープリタプロセスを立ち上げます。この「新しいプロセスの作り方」には、主に2つの方式が存在します。

2.1. Fork方式(UNIX/Linuxの伝統)

Unix系OS(Linuxなど)で長年デフォルトだったのが fork です。fork() システムコールは、現在の親プロセスの「完全なコピー」を作成します。メモリ空間、ファイル記述子、そして実行状態そのものが複製されます。

この方式の最大のメリットは「高速であること」、そして「親プロセスのメモリ内容をそのまま引き継げること」です。親プロセスで定義された worker_task 関数は、子プロセスのメモリ上の同じアドレスに既に存在しているため、何の問題もなく実行できます。

2.2. Spawn方式(Windows/macOSの標準)

対して、Windowsや最近のmacOSが採用しているのが spawn です。これは全く新しい、空っぽのPythonプロセスをゼロから立ち上げます。新しいプロセスは、親プロセスから何も引き継いでいません。

では、どうやって親プロセスで定義した worker_task を実行するのでしょうか? ここで 「シリアライズ(Pickle)」 という魔術が必要になります。

3. 深淵のPickle:関数を「送る」とはどういうことか?

spawn 方式では、親プロセスは実行したい関数(オブジェクト)をバイト列に変換し、パイプを通じて子プロセスに送り込みます。子プロセスはそのバイト列を受け取り、元のオブジェクトに復元(デシリアライズ)して実行します。この変換を担うのがPython標準の pickle モジュールです。

ここで重要なのは、「Pickleは関数のコードそのものをシリアライズしない」という事実です。

3.1. Pickleが保存するのは「名前」だけ

Pickleで関数をシリアライズすると、中身は概ね次のようになります。
__main__ モジュールにある worker_task という名前の関数を後で探してね」

子プロセスが起動すると、まず自分自身の環境をセットアップします。そして、送られてきた「指示書」を読みます。「えーと、__main__ モジュールの worker_task を実行しろって? よし、自分の __main__ の中を探してみよう……。ない! どこにもないぞ!

これが AttributeError の正体です。子プロセスにとって、スクリプトファイルは「再インポート」される対象です。もし適切なガード(if __name__ == '__main__':)がなかったり、対話型シェル(REPL)で定義された関数だったりすると、子プロセスが再度ファイルを読み込んだとき、その関数を正しく自分自身の __main__ 名前空間に登録できないのです。

4. コンピュータ・サイエンス的考察:Copy-on-Writeとメモリ隔離

ここで少し低レイヤの話をしましょう。なぜWindowsは fork を採用せず、わざわざ面倒な spawn を使うのでしょうか? そしてなぜ fork は時に危険なのでしょうか?

4.1. Copy-on-Write (CoW) の功罪

Linuxの fork は、実際にはメモリを即座にコピーしません。OSの仮想メモリ管理機能である Copy-on-Write (CoW) を利用します。物理メモリ上のページは共有されたままで、どちらかのプロセスが書き込みを行った瞬間に、そのページだけが複製されます。

しかし、Pythonのようなリファレンスカウントを持つ言語とは相性が最悪です。オブジェクトを読み取るだけで(参照カウントが増えるため)メモリへの書き込みが発生し、結局ほぼ全てのメモリがコピーされてしまう「CoWの崩壊」が起きるからです。

4.2. スレッドセーフティとデッドロック

さらに深刻なのがデッドロックです。親プロセスでスレッドが動いており、あるロックを確保していたとします。その状態で fork すると、子プロセスには「ロックを保持したまま死んでいるスレッド」の残骸がコピーされます。このロックは永遠に解放されません。これが、macOSが spawn をデフォルトに変更した大きな理由の一つです(Core Foundationなどのシステムライブラリが fork 安全ではないため)。

5. エラーの根本解決:3つの黄金律

理論がわかったところで、実践的な解決策を見ていきましょう。この AttributeError を回避するための鉄則は3つあります。

① エントリポイントの保護

最も基本的かつ必須の対策です。spawn 方式では、子プロセスは親のスクリプトを「インポート」します。このとき、if __name__ == '__main__': ブロックがないと、子プロセスが自分自身の中でさらに子プロセスを生成しようとする「再帰的無限ループ」に陥るか、今回のような属性エラーを引き起こします。

import multiprocessing

def worker():
    print("Working...")

if __name__ == '__main__':  # これが生命線
    p = multiprocessing.Process(target=worker)
    p.start()
    p.join()

② 関数を別モジュールに切り出す

__main__ という「特殊な名前空間」に依存するからエラーが起きるのです。関数を tasks.py のような別のファイルに定義し、それをインポートして使うようにすれば、Pickleは「tasks モジュールの worker を探せ」という明確な指示を子プロセスに送ることができます。子プロセスは import tasks を行うだけなので、確実にターゲットを発見できます。

③ シリアライザをdillに変更する(最終手段)

標準の pickle は、関数の「名前」しか送りません。しかし、サードパーティ製の dillcloudpickle を使うと、関数の「バイトコード(中身)」そのものをシリアライズして送ることができます。これにより、ラムダ関数や動的に生成された関数もマルチプロセスで飛ばすことが可能になります。

6. アナロジーで理解する:料理のレシピとキッチン

この複雑な状況を、料理に例えてみましょう。

Fork方式:
あなたはキッチン(親プロセス)で料理をしています。そこに魔法で「キッチンの完全なコピー」をもう一つ隣に作ります。コピーされたキッチンには、今切っている途中の野菜も、調味料の場所も、あなたの記憶(メモリ)もすべて再現されています。隣の人はそのまま続きを作るだけです。

Spawn方式:
あなたは新しいキッチンを更地に建てます(子プロセス)。そして、隣のキッチンにいる人に「『私のメモ帳』の3ページ目にある『秘密のソース』を作って」というメモを渡します。しかし、新しいキッチンには『私のメモ帳』が置いてありません。これが AttributeError です。

解決策としての別モジュール:
「『私のメモ帳』ではなく、『世界共通の料理本(別モジュール)』の40ページを見て」と指示します。これなら、新しいキッチンでも本棚から料理本を取り出して、正しく作ることができますね。

7. 現代のPythonにおけるマルチプロセスの進化

Python 3.12や3.13(開発中)において、このあたりの挙動にはさらなるメスが入っています。特に注目すべきは 「Per-Interpreter GIL」 の導入です。

これまで、Pythonの並列処理がこれほどまでに複雑だったのは、GIL(Global Interpreter Lock)という巨大なロックの存在ゆえに、スレッドではなくプロセスを使わざるを得なかったからです。しかし、サブインタープリタごとに独立したGILを持つようになれば、multiprocessing ではなく、より軽量な「サブインタープリタによる並列実行」が一般化する可能性があります。

そうなれば、Pickleによる重厚なシリアライズに頼らず、より効率的なメモリ共有(あるいは通信)が可能になるでしょう。しかし、それまでの間、我々はこの AttributeError と正しく付き合っていく必要があります。

8. 実践的なデバッグテクニック

もし、どうしても AttributeError が消えない場合、以下の手順でデバッグしてみてください。

  • sys.path の確認: 子プロセスが親プロセスと同じディレクトリ構造を認識しているか。 PYTHONPATH が通っているかを確認します。
  • multiprocessing.set_start_method('spawn', force=True): Linux環境であっても、あえて spawn を強制することで、Windows/macOS環境でのバグを事前にあぶり出すことができます。
  • シリアライズのテスト: pickle.dumps(your_function) を手動で実行してみてください。ここでエラーが出るなら、マルチプロセスに渡す前の段階でアウトです。

まとめ

AttributeError: Can't get attribute on <module '__main__'> は、単なるバグではなく、Pythonが「いかにして安全に、かつOSの制限の中で並列処理を実現しようとしているか」という試行錯誤の結晶です。

spawn 方式が選ばれる理由、Pickleが名前解決に頼っている仕組み、そして __main__ スコープの特殊性。これらを理解することで、あなたはもう二度とこのエラーに怯えることはありません。むしろ、このエラーが出るたびに「ああ、今はSpawn方式で世界が再構築されている最中なんだな」と、OSレベルの挙動に思いを馳せることができるはずです。

エンジニアとしての成長は、こうした「なぜ?」を突き詰める瞬間にこそあります。今回のディープダイブが、あなたのPythonライフをより豊かなものにすることを願っています。

おすすめの書籍

今回のようなPythonの内部構造や、プロフェッショナルな開発における作法をより深く学びたい方には、こちらの書籍を強くお勧めします。特にマルチプロセスや非同期処理、パッケージングといった「実戦で差がつく」トピックが網羅されており、本記事で触れたようなスコープの問題についても、より体系的な知識を得ることができます。上級者への階段を登るための必読書と言えるでしょう。

コメント

タイトルとURLをコピーしました