Pythonにおけるマルチプロセスとマルチスレッドの使用を分析する

9714 ワード

Pythonを批判する議論では、Pythonマルチスレッドがどんなに使いにくいかとよく言われます.またglobal interpreter lock(GILとも親しまれている)に対して、Pythonのマルチスレッドプログラムの同時実行を阻害していると指摘する人もいる.そのため、他の言語(例えばC++やJava)から回ってきた場合、Pythonスレッドモジュールはあなたが想像したように実行されません.説明しなければならないのは、Pythonで同時または並列のコードを書くことができ、いくつかのことを考慮すれば、パフォーマンスの大幅な向上をもたらすことができます.まだ読んでいない場合は、Eqbal Quaranの文章「Rubyでの同時と並列」を見ることをお勧めします.
本稿では、Imgurで最も人気のある画像をダウンロードするための小さなPythonスクリプトを書きます.画像を順番にダウンロードするバージョンから始めます.つまり、一つ一つダウンロードします.その前にImgurのアプリケーションを登録しなければなりません.まだImgurアカウントを持っていない場合は、まず1つ登録してください.
本明細書のスクリプトはPython 3.4.2でテストに合格した.少し変更すると、Python 2でも実行できるはずです.urllibは2つのバージョンで最も違いがある部分です.手を出す
「download.py」というPythonモジュールの作成から始めましょう.このファイルには、ピクチャリストを取得し、これらのピクチャをダウンロードするために必要なすべての関数が含まれています.これらの機能を3つの個別の関数に分けます.

  get_links
  download_link
  setup_download_dir


3番目の関数「setup_download_dir」は、ダウンロードのターゲットディレクトリを作成するために使用されます(存在しない場合).
ImgurのAPIは、HTTPリクエストがクライアントIDを持つ「Authorization」ヘッダをサポートすることを要求する.登録したImgurアプリケーションのパネルからこのクライアントIDを見つけることができ、応答はJSONで符号化されます.Pythonの標準JSONライブラリを使用して復号できます.画像をダウンロードするのはもっと簡単です.URLに基づいて画像を取得し、ファイルに書き込むだけです.
コードは次のとおりです. 

import json
import logging
import os
from pathlib import Path
from urllib.request import urlopen, Request
 
logger = logging.getLogger(__name__)
 
def get_links(client_id):
  headers = {'Authorization': 'Client-ID {}'.format(client_id)}
  req = Request('https://api.imgur.com/3/gallery/', headers=headers, method='GET')
  with urlopen(req) as resp:
    data = json.loads(resp.readall().decode('utf-8'))
  return map(lambda item: item['link'], data['data'])
 
def download_link(directory, link):
  logger.info('Downloading %s', link)
  download_path = directory / os.path.basename(link)
  with urlopen(link) as image, download_path.open('wb') as f:
    f.write(image.readall())
 
def setup_download_dir():
  download_dir = Path('images')
  if not download_dir.exists():
    download_dir.mkdir()
  return download_dir

次に、これらの関数を利用して画像を1つずつダウンロードするモジュールを書く必要があります.「single.py」と名付けました.これには、私たちの最も元のバージョンのImgurピクチャダウンロードの主な関数が含まれています.このモジュールは環境変数「IMGUR_CLIENT_ID」によりImgurのクライアントIDを取得する.「setup_download_dir」を呼び出してダウンロードディレクトリを作成します.最後にget_を使用links関数は画像のリストを取得し、すべてのGIFとアルバムのURLをフィルタリングし、「download_link」で画像をダウンロードしてディスクに保存します.次はsingle.pyのコードです.

import logging
import os
from time import time
 
from download import setup_download_dir, get_links, download_link
 
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.getLogger('requests').setLevel(logging.CRITICAL)
logger = logging.getLogger(__name__)
 
def main():
  ts = time()
  client_id = os.getenv('IMGUR_CLIENT_ID')
  if not client_id:
    raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!")
  download_dir = setup_download_dir()
  links = [l for l in get_links(client_id) if l.endswith('.jpg')]
  for link in links:
    download_link(download_dir, link)
  print('Took {}s'.format(time() - ts))
 
if __name__ == '__main__':
  main()


私のノートには、このスクリプトが19.4秒かけて91枚の画像をダウンロードしました.これらの数字はネットワークによって異なることに注意してください.19.4秒はあまり長くありませんが、もっと多くの画像をダウンロードするにはどうすればいいですか?90枚ではなく900枚かもしれません.平均1枚の画像をダウンロードするのに0.2秒、900枚なら3分くらいかかります.では9000枚の画像は30分かかります.良いニュースは、同時または並列を使用すると、この速度を著しく向上させることができます.
次のコード例では、固有モジュールと新しいモジュールをインポートしたimport文のみが表示されます.関連するすべてのPythonスクリプトは、このGitHub repositoryを簡単に見つけることができます.スレッドの使用
スレッドは最も有名な同時および並列を実現する方法の一つです.オペレーティングシステムは、一般的にスレッドの特性を提供します.スレッドはプロセスよりも小さく、同じメモリ領域を共有します.
ここでは、single.pyに代わる新しいモジュールを書きます.8つのスレッドがあるプールを作成し、メインスレッドを加えると合計9つのスレッドになります.8つのスレッドなのは、私のパソコンにはCPUコアが8つあり、1つのワークスレッドが1つのコアに対応しているので、見た目は悪くありません.実際には、スレッドの数は慎重に検討されており、同じマシンを走る他のアプリケーションやサービスなど、他の要因を考慮する必要があります.
次のスクリプトは、以前とほぼ同じですが、新しいクラス、DownloadWorker、Threadクラスのサブクラスがあります.無限ループを実行するrunメソッドは書き換えられた.反復のたびにself.queue.get()を呼び出し、スレッドの安全なキューからURLを取得しようとします.キューに処理する要素が表示されるまで、ブロックされます.ワークスレッドがキューから要素を取得すると、以前のスクリプトでディレクトリに画像をダウンロードするために使用されたdownload_linkメソッドが呼び出されます.ダウンロードが完了すると、ワークスレッドはタスク完了の信号をキューに送信します.これは、キューがキュー内のタスク数を追跡しているため、非常に重要です.作業スレッドがタスク完了の信号を出さない場合、「queue.join()」の呼び出しにより、メインスレッド全体がブロックされます. 

from queue import Queue
from threading import Thread
 
class DownloadWorker(Thread):
  def __init__(self, queue):
    Thread.__init__(self)
    self.queue = queue
 
  def run(self):
    while True:
      # Get the work from the queue and expand the tuple
      #            tuple
      directory, link = self.queue.get()
      download_link(directory, link)
      self.queue.task_done()
 
def main():
  ts = time()
  client_id = os.getenv('IMGUR_CLIENT_ID')
  if not client_id:
    raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!")
  download_dir = setup_download_dir()
  links = [l for l in get_links(client_id) if l.endswith('.jpg')]
  # Create a queue to communicate with the worker threads
  queue = Queue()
  # Create 8 worker threads
  #         
  for x in range(8):
    worker = DownloadWorker(queue)
    # Setting daemon to True will let the main thread exit even though the workers are blocking
    #  daemon   True        ,  worker    
    worker.daemon = True
    worker.start()
  # Put the tasks into the queue as a tuple
  #     tuple        
  for link in links:
    logger.info('Queueing {}'.format(link))
    queue.put((download_dir, link))
  # Causes the main thread to wait for the queue to finish processing all the tasks
  #                
  queue.join()
  print('Took {}'.format(time() - ts))

同じマシンでこのスクリプトを実行すると、ダウンロード時間が4.1秒になります!すなわち,以前の例より4.7倍速い.これはかなり速いですが、GILのため、このプロセスでは同じ時間に1つのスレッドしか実行されません.したがって、このコードは同時であるが並列ではない.それがまだ速くなっているのは、IO密集型のタスクだからです.プロセスは画像をダウンロードするのに苦労せず、主な時間はネットワークを待つのに費やした.これは、スレッドが大きな速度向上を提供する理由です.スレッドの1つの準備が完了するたびに、プロセスはスレッドを絶えず変換できます.Pythonや他のGILのある解釈型言語のスレッドモジュールを使用すると、実際にはパフォーマンスが低下します.コードがgzipファイルを解凍するなど、CPU密集型のタスクを実行している場合は、スレッドモジュールを使用すると実行時間が長くなります.CPU密集型タスクと本格的な並列実行では,マルチプロセスモジュールを用いることができる.
公式のPython実現――CPython――はGILを持っているが、すべてのPython実現がそうではない.例えば,IronPythonは,.NETフレームワークを用いて実現されるPythonにはGILがなく,Javaベースで実現されるJythonも同様にない.既存のPython実装を確認することができます.マルチプロセスの生成
マルチプロセスモジュールは、スレッドの例のようにクラスを追加する必要がないため、スレッドモジュールよりも使いやすいです.私たちが唯一しなければならない変更はメイン関数にあります.
マルチプロセスを使用するには、マルチプロセスプールを構築する必要があります.提供されるmapメソッドにより、URLリストをプールに渡し、8つの新しいプロセスが生成され、画像を並列にダウンロードします.これが本当の並列ですが、これは代価があります.スクリプト全体のメモリが各サブプロセスにコピーされます.私たちの例ではこれは大したことではありませんが、大規模なプログラムでは深刻な問題を引き起こしやすいです. 

from functools import partial
from multiprocessing.pool import Pool
 
def main():
  ts = time()
  client_id = os.getenv('IMGUR_CLIENT_ID')
  if not client_id:
    raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!")
  download_dir = setup_download_dir()
  links = [l for l in get_links(client_id) if l.endswith('.jpg')]
  download = partial(download_link, download_dir)
  with Pool(8) as p:
    p.map(download, links)
  print('Took {}s'.format(time() - ts))

ぶんさんタスク
スレッドとマルチプロセスモジュールが自分のコンピュータのスクリプトを実行するのに役立つことを知っています.では、異なるマシンでタスクを実行したい場合や、規模を拡大して1台のマシンの能力範囲を超える必要がある場合、どうすればいいですか.良い使用例は、ネットワークアプリケーションの長時間のバックグラウンドタスクです.時間のかかるタスクがある場合は、同じマシンで他のアプリケーションコードに必要なサブプロセスやスレッドを占有することは望んでいません.これにより、アプリケーションのパフォーマンスが低下し、ユーザーに影響を与えます.他の1台や多くの他の機械でこれらの任務を走ることができればいいです.
PythonライブラリRQは、このようなタスクに非常に適しています.シンプルで強力なライブラリです.まず、関数とそのパラメータをキューに入れます.関数呼び出しの表現をシーケンス化(pickle)し、これらの表現をRedisリストに追加します.タスクがキューに入るのは最初のステップで、何もしていません.タスクキューをリスニングできるworker(ワークスレッド)が少なくとも1つ必要です.
最初のステップは、あなたのパソコンにRedisサーバをインストールして使用するか、正常に使用できるRedisサーバの使用権を持っています.次に、既存のコードには小さな変更しか必要ありません.まずRQキューのインスタンスを作成し、redis-pyライブラリを介してRedisサーバに渡します.次に、「download_link」を呼び出すだけでなく、「q.enqueue(download_link,download_dir,link)」を実行します.Enqueueメソッドの最初のパラメータは関数であり、タスクが実際に実行されると、他のパラメータまたはキーワードパラメータが関数に渡されます.
最後のステップはworkerを起動することです.RQは、デフォルトのキュー上でworkerを実行できる便利なスクリプトを提供します.ターミナルウィンドウでrqworkerを実行すれば、デフォルトのキューのリスニングを開始できます.現在の作業ディレクトリがスクリプトと同じであることを確認してください.他のキューをリスニングしたい場合は、「rqworker queue_name」を実行し、queue_という名前の実行を開始します.nameのキュー.RQの良い点は、Redisに接続できれば、任意の数の機械で任意の数のworkerを走ることができます.そのため、アプリケーションの拡張性を向上させることができます.以下はRQバージョンのコードです. 

from redis import Redis
from rq import Queue
 
def main():
  client_id = os.getenv('IMGUR_CLIENT_ID')
  if not client_id:
    raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!")
  download_dir = setup_download_dir()
  links = [l for l in get_links(client_id) if l.endswith('.jpg')]
  q = Queue(connection=Redis(host='localhost', port=6379))
  for link in links:
    q.enqueue(download_link, download_dir, link)

しかし、RQはPythonタスクキューの唯一の解決策ではありません.RQは確かに使いやすく、簡単なケースで大きな役割を果たすことができますが、より高度なニーズがあれば、Celeryなどの他のソリューションを使用することができます.まとめ
コードがIO密集型であれば、スレッドとマルチプロセスが役立ちます.マルチプロセスはスレッドよりも使いやすいが、より多くのメモリを消費する.もしあなたのコードがCPU密集型であれば、マルチプロセスは明らかにより良い選択です.特に使用している機械はマルチコアまたはマルチCPUです.ネットワークアプリケーションでは、複数のマシンに拡張してタスクを実行する必要があります.RQはより良い選択です.