日々のあれこれφ(..)

もっぱら壁打ち

【Python】並行処理再入門1

読めはするけど設計できるかちょっと怪しい、知ってはいるけどいまいち分かった気になれない並行処理について勉強したことのメモです。

本当はコードも含めて載せる予定だったのですが、ちょっと疲れてきたので一旦用語の整理までで。

再入門の目的・ゴールとしては、

  • 並行処理を用いた設計イメージが沸くようになる
  • 実装するときに気をつけることを知る

を設定しています。

用語の整理

並行処理(Concurrent)

  • 特定のマシンの限られたリソースの中でマルチスレッドやマルチプロセスによって同時に処理を行うこと
  • 同時といっても実態はさまざま
    • 1つの重たいタスクを複数の処理が分担して行なっている(並列処理という言葉が指すもの)
    • 複数のタスクがあり、短時間で切り替えながら処理をして、あたかも同時に裁いているように見せかけているもの

並列処理(Parallel)

  • リソースが共有されているか否かに限らず、"完全に"同時処理を行なうこと
  • 並行処理の中の具体的な処理方式の一つに位置付けられている言葉
    • 並行という言葉だけだと、完全に同時実行なのかそう見せかけたような処理を指すのか判別できないが、並列は完全に同時実行されている

プロセス

  • 実行されているプログラムの処理のこと。プログラムがOSによって主記憶装置に読み込まれたもの
  • 役割
    • システムプロセス: OSの機能の一部を実行する
    • ユーザープロセス: 利用者の指示で実行される
  • ユーザーごとに起動できるプロセス数のソフトリミットとハードリミットが/etc/security/limits.confなどで定義されている
    • 確認例: ulimit -a
    • ちなみにこのulimitコマンドでユーザーやプロセスが使えるリソースの上限を変更できる
  • 作成できるプロセスの数自体に制限はない(CPUやメモリなどのリソースが枯渇しない限り)が、プロセスIDの上限が設定されている
    • 確認例: cat /proc/sys/kernel/pid_max

スレッド

  • CPU利用の単位のこと
  • 一つのプロセスは、一つ以上のスレッドで構成される
  • スレッド自身もプロセスの一種なので、ulimit -uの数を超えて作成することはできない
  • CPUのコアに命令を実行させるのはスレッド
  • サーバアプリケーションでは1リクエストや1コネクションにつき、1スレッド用意する必要がある
  • プロセスごとに作成できるスレッド数は/proc/[pid]/limitsで定義されているMax processesの値が適用される
  • システム全体でのスレッド数の上限が設定されている
    • 確認例: cat /proc/sys/kernel/threads-max

並行処理が必要なタスクのタイプ

CPUバウンドなタスク

  • タスクを実行している時間の大半が、CPUの処理に使われているもの
    • 例: 計算処理

I/Oバウンドなタスク

  • タスクを実行している時間の大半が、CPUの処理以外(ディクスへの入出力やリクエスト、その応答など)に使われているもの
    • 例: DBに接続して結果を返すようなAPIの処理

協調的マルチタスク(ノンプリエンプティブマルチタスク

  • 1つのCPUに対して、複数のアプリケーションが同時に稼働している際、OSではなくアプリケーション自身がCPUを制御し、開放するようなマルチタスクの実行方法
    • 昔はOSでも採用されていたが、システムのリソースをアプリケーション側が管理するこのやり方はなかなかリソースを手放さないアプリが出ると他の作業が滞ったりなど問題が多々あったらしく、現在のプリエンプティブマルチタスク(OSがプロセスやスレッドを直接管理して、コンテキストスイッチさせるやり方)方式になったとか

並行処理の実現手段

マルチスレッド

  • 複数のスレッドはCPUやメモリなどのリソースを共有する
  • シングルプロセッサ上でマルチスレッドが動いている場合、スレッドを順次切り替えることで見かけ上の並列処理を実現している
  • マルチプロセッサ上でマルチスレッドが動いている場合、各スレッドが別々のプロセッサ上で同時に動いていれば並列処理になる

マルチプロセス

  • 同じOS上に動く複数のプロセスは、それぞれ割り当てられた資源の中で独立して処理を行なっている
    • プロセスAが使っているメモリ領域にプロセスBがアクセスするようなことはできない

非同期プログラミング

並行処理の文脈でよく出てきて、位置付けを整理しておきたかったので軽く触れます。

  • 複数のプロセスやスレッドを使わず、複数のタスクを全て一つのプロセス、スレッド管理する
  • 故に並列処理にはならないコンテキストスイッチが発生するタイプの並行処理になるが、シングルプロセッサ上のマルチスレッドと違う点は、スイッチを行うのはOSではなくプログラム内部(特定の関数など)になる点
    • この制御する関数はイベントループスケジューラーと呼ばれている
  • Pythonでは非同期処理で扱う並行タスクをコルーチンと呼ぶ

Pythonの並行処理の実装手段

Pythonのマルチスレッド(threadingモジュール)

  • I/Oバウンドなタスク向き
  • 複数のスレッドはメモリを共有している
    • データを並行処理から保護することを考えないといけない
    • 保護しなかった場合、複数のスレッドが一つのデータを同時に更新しようとして予期しない結果になる(race hazard)
    • データの保護を間違えた場合はデッドロックとなりうるが、再入可能ロックを使うと一部のデッドロックを防げたりする
  • GIL(Global Interpreter Lock)の制約で、Pythonを実行するスレッドはグローバルロックにより常に一つに限定されている
  • 故にマルチコアのマルチスレッド下でもCPUバウンドな処理は1スレッドでしか実行されず、他のスレッドは待機状態になる
  • 標準ライブラリだけだと信頼性の高いシステムにするにはプールやキューを用意し、ワーカースレッドの例外を適切に処理するなど実装するものが多い

Pythonで並列処理をするなら知っておくべきGILをできる限り詳しく調べてみた - Qiita

マルチプロセスにすることで、プロセスごとにGILを持つため並列処理を実現できるようになります。

Pythonのマルチプロセス(multiprocessingモジュール)

  • CPUバウンドなタスク向き
  • メモリを共有しないのでデッドロックなどの心配がない
    • その代わりにプロセス間でデータ共有をするには実装が必要になるが、プロセス間通信に関しては簡単で信頼性のある機能が複数提供されている
  • 新しいインタプリンタを立ち上げるのでCPUバウンドなPythonコードもGILに邪魔されずに並列処理できる
  • プロセス間通信を行うため関数や返り値がpicklableでないといけない

これだけ読むと必ずしもマルチプロセスにして並列数を設定可能な最大値にすれば一番処理性能が良くなりそうに思えてくるかもしれませんが、一概にそうともいえなく、 マルチスレッド・マルチプロセスどれをどのくらいの設定にするのがいいかは、負荷試験などの検証を踏まえて決定する必要がある、そうです。

Pythonの非同期処理(asyncioモジュール)

  • I/Oバウンド向き
  • コルーチンを定義する

非同期と同期処理を合わせて使う(concurrent.futureモジュール)

  • I/Oバウンドなタスクなので非同期で行いたい処理の中に一部CPUバウンドなものが含まれていた場合、そのタスクを捌いている間は他のタスクが待機中になってしまうので非同期処理は有効でなくなってしまう
  • そういったケースにおいて一部のCPUバウンドな処理をプロセスに委譲し、委譲した処理をコルーチンのように扱えるようにして制御をイベントループに開放するといった、同期処理と非同期処理の良いとこどり(?)を実現する

おわりに

Pythonで並行処理を実装するにあたって、ドキュメントを見にいくと上記で取り上げたようないくつかのモジュールが紹介されている中で「処理をさせたいタスクの種類などに応じて判断してね」と書いてあるのですが、どうやってそれらを選べばいいのか最初は分かっていませんでした。そもそも並行処理とはを整理した上でPythonの実行時の特性(GILの話)を知ることでその判断基準を少しは持てたような気がします。

参考

他いろいろな記事をつまみ食いする形で穴を埋めました。