🍡

Pythonのマルチスレッドプログラミングで学んだことをまとめた

2021/01/24に公開

Pythonでマルチスレッドを意識したプログラムを書く機会があったので、その際に学んだことをまとめておく。

プロセスとスレッド

スレッド単体で理解するより、プロセスと合わせて理解したほうが捗る、というかそうしないとよくわからない気がする。

プロセスはOS上で動作しているプログラムの単位を表す。よく「プロセスをkillする」とか言うが、そのプロセスを殺すことでたとえば無限ループに陥ってしまったプログラムなどを強制的に停止させることができる。

Webサーバ(NginxやApache)もプロセスであり、クライアントからのリクエストを受け取り、新たにスレッド or プロセスを作成してそちらに処理を移譲する。

スレッドはプロセス内で生成されるプログラムの処理単位。つまり、OS上では複数のプロセスが動作していて、さらにそのプロセスごとに単一、もしくは複数スレッドが存在している。

プロセス、スレッドはそれぞれ並行に実行されるため、処理の途中にI/Oバウンドな処理があった場合などにそちらからCPUリソースを逃すことができる。フロントエンドエンジニアならJSの非同期処理に似たものとイメージするとピンと来やすいかも。ただあればイベントループという別の概念。

スレッドとプロセスの違い

スレッドとプロセスの違いについて、アプリケーション開発者が意識する点は、「基本的にプロセス間ではメモリを共有しない。スレッド間ではメモリを共有する」という点かなと思う。

メモリを共有しない、ということはソースコード中の変数の中身は独立して扱われるということになる。これだけだとピンとこないので、Webサーバの例で考えてみる。

たとえばPythonのWebサーバ構成でよく使われるGunicornでは、workerという単位で起動するプロセス数を指定することができ、それぞれのworker内で並行に処理を行うことができる。結果、多数のリクエストが来た際の処理効率が向上する。

それぞれのWorker内ではメモリを共有しないので、「並行に走っている他のプロセスがこの変数の中身を書き換えるかも...」みたいな心配がない。直感的にプログラミングできると言える。

a = 1

print(a) # 1が表示される

複数スレッドの処理になると、スレッド分岐前に宣言した変数の中身(メモリ)は分岐後のスレッドで共有される。つまり、片方のスレッド内でその変数を書き換えた場合、もう片方のスレッドにも影響が出る。明示的になんらかの指定(ロックなど)を行わない場合、スレッドごとの処理順序を指定することはできないため、変数の中身は書き換わっているかもしれない、ということを念頭に置いてプログラミングする必要がある。

# 以下はイメージなので、実際のコードとは異なる
a = 1
print(a) # 1が表示される

# スレッド分岐処理
# 別スレッドでa = 2のような処理があるとする

print(a) # 1 or 2が表示される

こういった挙動は直感的ではなく、バグが混入しやすくなる。なので、マルチスレッドを使わないで済むなら使わないに越したことはないと思う。

また、複数プロセスなら上記のような問題が絶対に起こらない、というわけではない。たとえば同一ファイルの書き換え時などに意図しない結果が生じる可能性がある。

Pythonでマルチスレッドプログラミング

Pythonには標準ライブラリとしてthreadingがあり、これを使うことでマルチスレッドを用いてプログラミングができる。

単純にスレッドを分岐させるだけであればこう書ける。

import threading
  
def worker():
  print("success threading")

def main():
  t = threading.Thread(target=worker)
  print("thread start")
  t.start()

if __name__ == '__main__':
  main()

threading.Threadでスレッドオブジェクトを作成し、'start()'で実際に処理をスタートさせる。上記の例だと別にスレッドを分けても恩恵は何もないが。。。

実際の開発ではあまりやらないが、以下のようにするとスレッドを分ける意味が少しは出てくる。

import threading
import time

def worker():
  while True:
    time.sleep(100) 
    # 定期的に行いたい処理

def main():
  t = threading.Thread(target=worker)
  t.daemon = True
  t.start()

  while True:
    time.sleep(50):
      # 定期的に行いたい処理

if __name__ == '__main__':
  main()

こうすると、一つのプログラム内において別々のタイミングで何かの処理を定期実行できる。

実際に使う場合は、処理の途中でネットワークリクエストやファイルアクセスなどのブロッキング処理がある場合に、それを別スレッドに逃すみたいなケースが考えられる。今回の開発ではそれらとは違う理由で利用したが、いつでも使えるようにしておくと便利だと思う。

スレッドのロックについて

複数のスレッドが実行されている環境において、ある一連の処理は不可分(atomic)に処理したいことがある。前述したように、スレッドは並行に実行され、かつ処理順序は指定できないのである操作とある操作の間に別スレッドの処理が挟まった結果、意図しない結果になる可能性がある。

with open(path, 'r+') as f:
  content = f.read()
  # ← ここで別スレッドがこのファイルに対して何か書き込んだ場合、contentには反映されずに削除されてしまう
  f.truncate(0)

上記の例だと、ファイルの内容を読み出して、そのあとファイル内容を削除している。単一スレッドでの実行であれば、このように書いても問題は起こらない。

ただ、複数スレッドが実行されている状態で、かつ同一ファイルになにかしら書き込むような処理が途中にある場合は、各スレッドの実行順によっては書き込んだ内容がreadされずに削除されてしまう可能性がある。

これを防ぐには、read()とtruncate()は必ず連続して実行されるようにしたい。こういった場合に使えるのがロック。以下のように書くと、readとtruncateは不可分に実行されることが保証される。

from threading import Lock

lock = Lock()
with lock:
  with open(path, 'r+') as f:
      content = f.read()
       f.truncate(0)

withを使って書いているが、acquire()release()で明示的に書くこともできる。

ロックを取得したスレッドは、それを解放するまでCPUリソースを独占できる。ただ、ロックを解放しない限り、CPUがそのスレッドに貼り付け状態になるので注意。ちゃんと考えて使わないと複数のスレッド間でお互いにロックの解放待ち状態になり、プログラムがフリーズしてしまうことがある(デッドロック)

やはり直感的な挙動ではなく、バグが混入するとデバッグがめちゃくちゃ大変になりかねないので、本当に必要な場合にのみ使うようにしたほうが良さそう、とは思う。

まとめ

プロセス、スレッドの理解については詳解UNIXプログラミング 第3版がめちゃくちゃ役に立った。おすすめ。

Discussion