並行処理と並列処理:ウェブスクレイピングにおける大きな違い

スクレイピング, 違い, Jan-1720225分で読める

並行処理と並列処理といえば、マルチスレッド環境でのコンピュータ・プログラムの実行における同じ概念を指すので、一目瞭然かもしれない。オックスフォード辞書の定義を見れば、そう思うかもしれない。しかし、これらの概念をさらに深く掘り下げると、次のようになる。

並行処理と並列処理というと、マルチスレッド環境でのコンピュータ・プログラムの実行における同じ概念を指すように思われるかもしれない。オックスフォード辞書の定義を見れば、そう思うかもしれない。しかし、CPUがプログラム命令を実行する方法に関してこれらの概念を深く掘り下げていくと、並行処理と並列処理が2つの異なる概念であることに気づくだろう。 

この記事では、並行処理と並列処理について深く掘り下げ、それらがどのように異なるのか、そしてプログラム実行の生産性を向上させるためにそれらがどのように連携するのかを説明する。最後に、どの2つの戦略がウェブスクレイピングに最も適しているかについて説明する。それでは始めよう。

同時実行とは何か?

まず、物事を簡単にするために、1つのプロセッサで実行される1つのアプリケーションにおける同時実行について説明します。Dictionary.comでは、同時実行を「行動や努力が組み合わされ、同時に事象が発生すること」と定義している。しかし、並列実行についても、実行が一致することから同じことが言えるので、コンピュータ・プログラミングの世界では、この定義はやや誤解を招きやすい。

日常生活では、コンピューター上で同時実行することがある。例えば、Windows Media Playerで音楽を聴きながら、ブラウザでブログの記事を読むかもしれない。別のウェブページからPDFファイルをダウンロードするという別のプロセスも実行されているだろう。

同時実行アプリケーションの発明以前は、CPUはプログラムを順次実行していた。これは、CPUが次のプログラムに移る前に、あるプログラムの命令の実行が完了しなければならないことを意味していた。

対照的に、並行実行は、すべてが完了するまで、各プロセスを少しずつ交互に実行する。

シングルプロセッサーのマルチスレッド実行環境では、ユーザー入力のために別のプログラムがブロックされているときに、あるプログラムが実行される。さて、マルチスレッド環境とは何かと聞かれるかもしれない。スレッドについては次のセクションで詳しく説明する。

同時実行は並列実行と混同してはならない

さて、それでは並行処理と並列処理を混同しやすくなる。上記の例で並行性というのは、プロセスが並列に実行されていないという意味である。 

その代わりに、あるプロセスが入出力操作を完了する必要があるとすると、オペレーティング・システムは、そのプロセスが入出力操作を完了する間、CPUを別のプロセスに割り当てる。この手順は、すべてのプロセスが実行を完了するまで続けられる。

しかし、オペレーティング・システムによるタスクの切り替えはナノ秒やマイクロ秒単位で行われるため、ユーザーにはプロセスが並列に実行されているように見える、 

スレッドとは何か?

逐次実行とは異なり、現在のアーキテクチャでは、CPUはプロセス/プログラム全体を一度に実行することはできない。その代わり、ほとんどのコンピュータは、プロセス全体をいくつかの軽量コンポーネントに分割し、任意の順序で互いに独立して実行することができる。これらの軽量コンポーネントをスレッドと呼ぶ。

例えば、グーグル・ドックスは複数のスレッドで同時に動作しているかもしれない。あるスレッドが作業を自動的に保存している間に、別のスレッドがバックグラウンドで動作し、スペルや文法をチェックしているかもしれない。  

どのスレッドを優先させるかは、オペレーティング・システムが決定する。

並列実行とは何か?

CPUが1つしかない環境でのコンピュータプログラムの実行はお分かりいただけたと思う。これに対して最近のコンピュータは、並列実行と呼ばれる複数のCPUで同時に多くの処理を実行する。現在のほとんどのアーキテクチャは複数のCPUを搭載している。

下の図を見てわかるように、CPUはプロセスに属する各スレッドを互いに並列に実行する。  

並列処理では、オペレーティング・システムは、システム・アーキテクチャに応じて、マクロ秒またはマイクロ秒の単位で、スレッドをCPUとの間で切り替える。オペレーティング・システムが並列実行を実現するために、コンピューター・プログラマーは並列プログラミングとして知られる概念を使用する。並列プログラミングでは、プログラマーは複数のCPUを最大限に活用するコードを開発する。 

ウェブスクレイピングを高速化する並行処理とは?

多くのドメインがウェブサイトからデータをスクレイピングするためにウェブスクレイピングを利用する中、重要な欠点は膨大な量のデータをスクレイピングするために消費する時間である。もしあなたが熟練した開発者でないなら、最終的にエラーなく完璧にコードを実行する前に、特定のテクニックを試して多くの時間を無駄にしてしまうかもしれない。

以下のセクションでは、ウェブスクレイピングが遅い理由のいくつかを概説する。

ウェブスクレイピングが遅いのはなぜか?

まず、スクレイパーはウェブスクレイピングのターゲットとなるウェブサイトに移動しなければならない。次に、スクレイピングしたいHTMLタグからエンティティを取得する。最後に、ほとんどの場合、CSV形式などの外部ファイルにデータを保存することになる。  

このように、上記のタスクのほとんどは、ウェブサイトからデータを取り出し、外部ファイルに保存するといった、負荷の高いI/O操作を必要とする。ターゲット・ウェブサイトへのナビゲートは、ネットワーク速度やネットワークが利用可能になるまでの待ち時間など、外部要因に依存することが多い。

下の図を見てわかるように、3つ以上のウェブサイトをスクレイピングしなければならない場合、この極端に遅い時間消費は、スクレイピング・プロセスをさらに不利にする可能性がある。これは、スクレイピング作業を連続して行うことを想定しています。

したがって、いずれにせよ、スクレイピング操作に並行処理や並列処理を適用する必要がある。次のセクションでは、まず並列処理について説明する。

Pythonを使ったWebスクレイピングにおける並行処理

並行処理と並列処理についての概要はもうご理解いただけたと思います。このセクションでは、Pythonによる簡単なコーディング例を用いて、ウェブスクレイピングにおける並行処理に焦点を当てます。

同時実行を使わない簡単な例

この例では、ウィキペディアから、人口に基づいた首都のリストで国のURLをスクレイピングする。プログラムはそのリンクを保存し、240の各ページに行き、それらのページのHTMLをローカルに保存する。

 同時実行の効果を示すために、2つのプログラム(1つは逐次実行、もう1つはマルチスレッドによる同時実行)を示す。

これがコードだ:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time

def get_countries():
    countries = 'https://en.wikipedia.org/wiki/List_of_national_capitals_by_population'
    all_countries = []
    response = requests.get(countries)
    soup = BeautifulSoup(response.text, "html.parser")
    countries_pl = soup.select('th .flagicon+ a')
    for link_pl in countries_pl:
        link = link_pl.get("href")
        link = urljoin(countries, link)
        
        all_countries.append(link)
    return all_countries
  
def fetch(link):
    res = requests.get(link)
    with open(link.split("/")[-1]+".html", "wb") as f:
        f.write(res.content)
  

        
def main():
    clinks = get_countries()
    print(f"Total pages: {len(clinks)}")
    start_time = time.time()
    for link in clinks:
        fetch(link)
 
    duration = time.time() - start_time
    print(f"Downloaded {len(links)} links in {duration} seconds")
main()

コードの説明

まず、HTMLデータを抽出するためにBeautifulSoapなどのライブラリをインポートする。その他のライブラリには、ウェブサイトにアクセスするためのrequest、URLを結合するためのurllib、プログラムの総実行時間を調べるためのtimeライブラリがある。

インポートリクエスト
from bs4 import BeautifulSoup
from urllib.parse import urljoin
インポート時間

プログラムはまずメインモジュールから始まり、get_countries()関数を呼び出す。そして、この関数はHTMLパーサーを通してBeautifulSoupインスタンスを経由してcountries変数で指定されたWikipedia URLにアクセスする。

そして、アンカー・タグのhref属性の値を抽出することで、テーブルの国のリストのURLを検索する。

取得したリンクは相対リンクです。urljoin関数は、それらを絶対リンクに変換します。これらのリンクは all_countries 配列に追加され、メイン関数に返されます。 

そしてフェッチ関数は、各リンクのHTMLコンテンツをHTMLファイルとして保存する。これが、これらのコードの断片が行うことである:

def fetch(link):
    res = requests.get(link)
    with open(link.split("/")[-1]+".html", "wb") as f:
        f.write(res.content)

最後に、メイン関数はファイルをHTML形式で保存するのにかかった時間を表示する。我々のPCでは131.22秒かかった。

さて、この時間はもっと速くできるはずだ。同じプログラムを複数のスレッドで実行する次のセクションで、それを見つけることにしよう。

同じプログラムに並行処理を加えたもの

マルチスレッド・バージョンでは、プログラムの実行速度が速くなるように、細かい変更を加えなければならない。

覚えておいてほしいのは、同時実行とは複数のスレッドを作成してプログラムを実行することだ。スレッドを作成するには、手動で作成する方法とThreadPoolExecutorクラスを使用する方法がある。 

手動でスレッドを作成した後、手動メソッドのすべてのスレッドでjoin関数を使うことができる。そうすることで、メイン・メソッドはすべてのスレッドの実行完了を待つことになる。

このプログラムでは、concurrent.futuresモジュールの一部であるThreadPoolExecutorクラスを使ってコードを実行する。そのため、まず上記のプログラムに以下の行を記述する必要がある。 

from concurrent.futures import ThreadPoolExecutor

その後、HTMLコンテンツをHTML形式で保存するforループを以下のように変更することができる:

  with ThreadPoolExecutor(max_workers=32) as executor:
           executor.map(fetch, clinks)

上記のコードでは、最大32スレッドのスレッド・プールを作成している。CPUごとにmax_workersパラメータは異なるので、いろいろな値を試してみる必要がある。スレッド数が多いほど実行時間が速くなるとは限らない。

つまり、我々のPCでは15.14秒の出力となり、シーケンシャルに実行した場合よりもはるかに優れている。

次のセクションに進む前に、同時実行プログラムの最終的なコードを以下に示す:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from concurrent.futures import ThreadPoolExecutor
import time

def get_countries():
    countries = 'https://en.wikipedia.org/wiki/List_of_national_capitals_by_population'
    all_countries = []
    response = requests.get(countries)
    soup = BeautifulSoup(response.text, "html.parser")
    countries_pl = soup.select('th .flagicon+ a')
    for link_pl in countries_pl:
        link = link_pl.get("href")
        link = urljoin(countries, link)
        
        all_countries.append(link)
    return all_countries
  
def fetch(link):
    res = requests.get(link)
    with open(link.split("/")[-1]+".html", "wb") as f:
        f.write(res.content)


def main():
  clinks = get_countries()
  print(f"Total pages: {len(clinks)}")
  start_time = time.time()
  

  with ThreadPoolExecutor(max_workers=32) as executor:
           executor.map(fetch, clinks)
        
 
  duration = time.time()-start_time
  print(f"Downloaded {len(clinks)} links in {duration} seconds")
main()

並列処理でウェブスクレイピングを高速化

さて、並行実行について理解していただけただろうか。より良く分析するために、同じプログラムが複数のCPUで並列に実行されるマルチプロセッサ環境でどのように実行されるかを見てみましょう。

まず、必要なモジュールをインポートしなければならない:

from multiprocessing import Pool,cpu_count

Pythonには、マシンのCPU数をカウントするcpu_count()メソッドが用意されている。これは、並列に実行できるタスクの正確な数を決定するのに役立つことは間違いありません。

ここで、逐次実行のforループのコードを次のコードに置き換える必要がある:

with Pool (cpu_count()) as p:
 
   p.map(fetch,clinks)

このコードを実行した結果、全体の実行時間は20.10秒となり、最初のプログラムの逐次実行よりも比較的速くなった。

結論

この時点で、並列プログラミングと逐次プログラミングの包括的な概要を理解していただけたと思う。

ウェブスクレイピングのシナリオでは、同時実行から始めて並列ソリューションに移行することをお勧めします。この記事を楽しんでお読みいただけたなら幸いです。このようなウェブスクレイピングに関連する他の記事も、当ブログで忘れずにお読みください。