ホーム 主筆 その他ソフト その他情報 Syuhitu.org English

マルチスレッド開発の傾向と対策

序論

複数のスレッドをグリグリ回すようなプログラムを作るのは大変である。

ソフトウェアは本質的に複雑であるといったのはブーチとかいうおっさんだそうだが、命令を頭から一つずつ順序よく実行するシングルスレッドのプログラムだけでも複雑だというのだから、その前提を覆すマルチスレッドのプログラムに至っては、その複雑さは筆舌に尽くしがたい。

マルチスレッドのプログラムを作る場合には、単純明快なアーキテクチャで、明瞭簡潔な方針に則り開発を行わなければならない。

さもないと、どの部分がどのスレッドによりいつ実行されるのか、メモリ領域のどの部分がいつ更新されるのか、全く予想もつかないような、恐ろしいプログラムができあがってしまう。

このページでは、そんな不確定性地獄を回避するために、複数スレッドを使用するプログラムにおいてよく使用される、全体的なアーキテクチャのような物について講釈を垂れたいと思う。

前提

ご大層なお題目を掲げたのだから、ここはやはり、できるだけOSに依存しない考え方について述べたいと思う。また、必要に応じてサンプルのコードを示しながら解説したいとも思う。

だからここでは、スレッドや同期オブジェクトを抽象化し、OSやライブラリの違いを吸収するような、自家製のライブラリを使って話を進めようと思う。

ということで、下記のようなライブラリを作った。

スレッド生成 Thread.h Thread.cpp

セマフォ Semaphore.h Semaphore.cpp

余り疑問に思う余地はないはずだが、とりあえず解説しておく。

まず、セマフォの方が簡単だから、それを先に解説する。

このクラスは、コンストラクタ・デストラクタを含めて、下記4つのメソッドを提供している。

項番 CSemaphore Win32 POSIX 解説
1
CSemaphore();
CreateSemaphore
sem_init
セマフォを構築する
2
~CSemaphore();
CloseHandle
sem_destroy
セマフォを破棄する
3
P();
WaitForSingleObject
sem_wait
セマフォで待ち合わせを行う
4
V();
ReleaseSemaphore
sem_post
セマフォを解放する

コンストラクタ(CSemaphore();)でセマフォを構築し、デストラクタ(=CSemaphore();)でセマフォを破棄する。P();で待ち合わせを行い、V();で解放する。

使い方としては、下記のようになる。

#include "Semaphore.h"

CSemaphore sema;  // セマフォ

void foo()
{
  sema.P();  // クリティカルセクションの開始

  // ここにクリティカルセクション内の処理を記述する

  sema.V();  // クリティカルセクションの終了
}

関数fooが複数スレッドで実行された場合でも、「sema.P();」と「sema.V();」の間は排他制御がかけられ、同時に実行できるスレッドは一つだけに制限される。

スレッドの方はセマフォに比べて、もうちょっと複雑な構造になっている。

このクラスでは、下記2つのメソッドを提供している。コンストラクタとデストラクタは、特筆すべき処理は行っていない。

項番 CThread Win32 POSIX 解説
1
start();
CreateThread
pthread_create
スレッドを生成し、runメソッドを呼び出す
2
run();
ThreadProc
start_routine
スレッドのエントリポイント

このクラスは、start()が呼び出されるとスレッドを生成し、生成したスレッドによってrun()を実行する。

だが、CThread::run()自体では特に何の処理も行っておらず、呼び出されても、そのまま終了するだけである。だから、このクラスのユーザは、CThreadクラスを継承した新しいクラスを作り、具体的な処理を行うrun()メソッドを作る必要がある。

使用例を見た方が判りやすいと思う。

#include "Thread.h"

class CFoo : public CThread
{
public:
  void run()
  {
    // ここは別のスレッドで処理される。
  };
};

void goo()
{
  CFoo f1, f2;

  // スレッドを2つ生成する
  f1.start();
  f2.start();
}

上記のプログラムでは、CFoo::run()は関数goo()を実行しているスレッドとは異なるスレッドで実行される。また、関数goo()ではCFooのインスタンスを2つ作って、それぞれに対してstart()メソッドを呼び出しているため、CFoo::run()は2つのスレッドで同時に実行されることになる。

CSemaphoreもCThreadも、それ自身ではほとんど何の処理も行ってはいない。だが上で述べたとおり、解説の都合上からこの2つのクラスを使って話を進めていくことにする。

分類

複数スレッドで処理を実現するプログラムは、大きく分けると次の二つに分類できる。

  1. 非対称型(垂直)
  2. 対称型(水平)

非対称型というのは、複数あるそれぞれのスレッドが、異なる処理を行うようなパターンの事である。このような処理の分割が行われるのは、主に開発の容易性(考え方の簡略化)や、外部からの制約を満たす必要がある場合などである。処理速度の向上を目的として、このような分割方法がとられることは少ない。

対称型というのは、複数のスレッドがそれぞれ同じ処理を行うようなパターンのことである。つまり、大量にある処理(トランザクションやデータなど)を、複数のCPUを使って高速に処理しようとするような場合である。

余談だが、学者連中がマルチスレッドと言えば、大抵は対称型の分割について考えている事が多いようだが、実際のビジネスの現場では非対称型の分割になる場合の方が多いようだ。

なぜかと言えば、業務アプリケーションでは高速化を目的としたマルチスレッド化は、あまり行われないためだ。

クライアントPCで動作する業務アプリケーションがCPUバウンドになることはほとんどありえないし、サーバ側でも、スレッド関連の複雑なロジックは基盤となるTPモニタやAPサーバが処理してくれる。

だから、業務アプリケーションでマルチスレッド化が行われる場合というのは、外部的制約により仕方なくそうするといった場合が、ほとんど全てとなる。

まぁ、究極的には「業務」の内容に依るんだけど。

対称と非対称の、それぞれの分割方法の中には、更にいくつかの代表的パターンと言うのが考えられる。

それをいくつか書き出してみる。

非対称型

  1. UI-作業スレッド分割
  2. メッセージ分割

対称型

  1. サービス分割
  2. ループ分割

名前は全部俺が勝手に付けた。

また、他にもいろいろと、パターンと言えるものもあるのだろうが、とりあえず俺に思いつくのはこれぐらいだ。

それに、これ以外の奇妙なやり口で実装しようとしても、なかなかうまくいかないようだ。

ということで、上記のパターンに焦点を絞って、その考え方や実装方法について解説してみたいと思う。

解説

UI-作業スレッド分割

ユーザインターフェースを司るスレッドから、負荷の高い処理を行う作業用スレッドを生成するパターン。

重い処理を行っている間、画面が更新されなくなりユーザからの操作も受け付けられなくなる問題を解決するために行われる事が多い。

メッセージ分割

あるスレッドから、他のスレッドにメッセージを送信することで処理の依頼を行う、という考え方。この場合、各スレッドが、オブジェクト指向で言うところの「オブジェクト」と1対1の関係を保つように作成されるのが一般的となる。

原理的な意味でのオブジェクト指向の概念に近いコーディングとなる。

サービス分割

サーバのプロセスの実装で利用される。

クライアントから接続されると、そのクライアントからの要求を処理するスレッドを生成する。クライアントとスレッドは1対1で対応し、クライアントからの接続が切られると、対応するスレッドも消滅する。

ループ分割

OpenMPが前提としているマルチスレッド化方式。

配列の各要素に対する演算をループを使って処理している場合、そのループを複数のスレッドを使って高速に処理しようと言う考え方。

マルチメディア処理などでは利用価値は高いようだが、システム寄りの制御的な処理を行うプログラムや、WebAPや、あるいは料金計算のような業務処理ではほとんど使い道はないようだ。

何度も言うが、他にも「パターン」はあるのだろうが、とりあえずここではこれだけにしておく。

最後に

スレッドは、プロセス内でメモリやファイルディスクリプタなど様々なものを共有して動作している。そのため、えてして非常に破壊的な処理を行ってしまう事も起こりがちである。

だからこそ、明瞭なルールを定め、常にそのルールに従ってプログラムを作るようにしなければならない。多数のスレッドが、様々なリソースに無秩序にアクセスするような実装は、決して優れた設計とは言えない。

また、一般的に言って、あるスレッドが走り回る範囲・境界は、明確に定めておいた方が良いようだ。特に、スレッドが実行される境界と、モジュールの境界を合わせておくと都合が良い。

例えば、下記の下手な絵は、A・B・Cの3つのモジュールが存在し、スレッド1がモジュールAとCを実行し、スレッド2がモジュールBとCを実行することを表している。

それが、例えば下記のようになったとしたらどうだろうか。

主としてモジュールAとCを実行するスレッドが、時折モジュールBの中のある関数をも実行する可能性があるというような状態だ。

もとよりモジュールCは2つのスレッドで共有されているのだし、モジュールB内の一部の関数が共有されたところで大した違いはない、と思う人もいるかも知れない。だが、それは大きな間違いだ。このような「例外」は、ソフトウェアの複雑性を増し、寿命を縮める要因となる。

特に、世の中全てのプログラマがスレッドについて理解しているとは限らない事は、十分肝に銘じておかなければならない。