2014年1月26日日曜日

プロセスとスレッドの話

1.プロセスとは?


WindowsやLinux等のOSでは、ワープロやブラウザなどのアプリケーションを実行するとプロセスというものを作ってその中でアプリケーションを動かします。

コンピュータの仕組みをざっくり話すと、CPU(処理実行)、メモリ(データ記憶)、I/O(外部入出力)の3つで出来ていますが、 このCPUとメモリの部分を仮想的に作ったものがプロセスです。

なんとなくのプロセスのイメージです

実際のCPUとメモリは、複数の仮想的なCPUとメモリを切り替えながら動作させることになります。この複数の仮想的なCPUとメモリはそれぞれは完全に分離されています。その事からプロセスには以下の利点があります。
  1. 分離された環境でプログラムを動かせば良いのでプログラミングが楽。
  2. メモリが分離されているので、他のアプリケーションから干渉を受けず、セキュリティに強い。
最近はセキュリティが大きく取り上げられることも多く、Chromeなどのブラウザでは1つのアプリケーションに見えても、タブごとに複数のプロセスが作るなどの工夫がされています。

2.WindowsとLinuxのプロセス生成の違い


WindowsとLinux(正確にはUNIX系)ではプロセス生成の仕方が少しだけ違っています。Windows(Win32 API)では以下のようにCreateProcess関数でプロセスを作成します。引数の詳細の説明は省きますが、notepad.exe(メモ帳です)のようにアプリケーションを指定してプロセスを作成します。

#include <windows.h>
#include <tchar.h>

int main()
{
        STARTUPINFO si;
        PROCESS_INFORMATION pi;
        BOOL r;

        ZeroMemory(&si, sizeof(si));
        si.cb = sizeof(si);
        r = CreateProcess(_T("C:¥¥Windows¥¥System32¥¥notepad.exe"), NULL,
                                NULL, NULL,
                                FALSE,
                                0,
                                NULL,
                                NULL,
                                si, &pi);
        if (r) {
                MessageBox(0, _T("CreateProcess Success!"), _T("Test"), MB_OK);
                CloseHandle(pi.hThread);
                CloseHandle(pi.hProcess);
        }
        return 0;
}

これに対してLinux(UNIX系)では以下のようにfork関数とexec関数の2つを使用します。fork関数の時点でプロセスが作られますが、ここでは自分自身のコピーのプロセスを作成します。新しく作成したプロセスかどうかは返り値で判断できます。そして、exec系関数(execl, execlpなど色々とある)で指定されたアプリケーション(ここではgedit、GNOMEでのメモ帳みたいなソフト)に置き換えをします。

#include <unistd.h>
#include <stdio.h>
#include <signal.h>

int main()
{
        pid_t pid;

        pid = fork();
        if (pid == 0) {
                execlp("gedit", "gedit", NULL);
        } else if (pid != -1) {
                struct sigaction act;

                printf("create process success!\n");
                act.sa_handler = SIG_IGN;
                act.sa_flags = 0;
                sigemptyset(&act.sa_mask);
                sigaction(SIGCHLD, &act, NULL);
        }
        return 0;
}

一旦プロセスのコピーをするので無駄な処理を行っているように見えますが、以下のような利点もあります。
  1. 別の実行ファイルを作らなくても複数プロセスが作成できる。
  2. 同じようなプロセスを複数作りたいときにコピーが簡単に作成できる。
また、プロセスのコピーといってもCOW(Copy On Write)という方式を使っており、ほとんどオーバーヘッドはありません。

なかなか利用法がないように思えますが、Androidにおいては、主要なライブラリのみ読み込んだプロセスを作っておいて、そのプロセスをforkすることで、高速に新しいプロセスを作成できるようになっています(通常はプロセスを作ってからライブラリを読み込みます)。そのためJavaであってもアプリケーションの起動が速いです。

3.スレッドとは?


プロセスはCPUとメモリを仮想的に作成したものでしたが、メモリ部分だけは共通にCPU部分だけを分けたい事があります。この場合に使われるのがスレッドです。

例えば、長い処理をウィンドウの処理内で行うと、ウィンドウ管理システムが応答が無いとの警告を出す場合があります。こういうとき、長い処理はスレッドで行い、ウィンドウ処理では「お待ちください」 というメッセージと共にプログレスバーを表示するなどで使われます。

以下のような利点があります。
  1. プロセスだとメモリが分離しているため、データのやりとりのプログラミングが難しいし、データのやりとりの速度が遅い。
  2. プロセス(CPUとメモリ)を切り替えるより、スレッド(CPU)のみ切り替えたほうが速度が早い。

4.WindowsとLinuxでのスレッドの違い


使い方などはほとんど同じですが、WindowsとLinuxでは、実装で少し違いがあります。

Windowsでは、スレッドはプロセスの中に在るものというようなイメージになっています。CreateProcess関数の例でもプロセスを作るとスレッドも同時にできるので、CloseHandle関数でスレッドとプロセスの両方を解放しています。

Linuxでは、スレッドは軽量プロセスというような意味合いになっており、メモリを共有したプロセスのように扱われています。実際プロセスの作成のfork関数もスレッド作成の関数も内部的にはclone関数が呼ばれており、プロセスとスレッドの差が小さいです。

スレッドのイメージ。Windowsが左側、Linuxが右側

5.マルチCPUと並行処理


最近のコンピュータでは、単体のCPUの速度向上が頭打ちになってきました。そこで、複数のCPUを搭載して処理速度をあげようとしています。

そのため、プロセスやスレッドの利用目的として、速度向上の役割ができてきました。1つの事でも複数で同時に並行処理をすることで、処理速度をあげます。しかし1つの事を同時に複数に行うには

並行処理の方法には大きく以下の3つの方法があります。
  1. データ並列化
  2. タスク並列化
  3. パイプライン並列化
 例えば、千羽鶴を多人数で作成することを想定します。折り紙がデータとなります。

データ並列化は1人が何枚ずつ折り紙を折るかに分けて作業する方法です。1人ごとの折り紙の量を間違うと全体として完了は遅くなります。

タスク並列化は、折り紙を一箇所に置いておき、 それを皆が一枚づつ取っていき鶴を折ります。一箇所に折り紙が置いてあるので、取るとき手がぶつかる可能性があり、それを避ける仕組みが必要です。

パイプライン並列化は流れ作業です。1人ずつ半分に折る担当、羽を折る担当などの作業の担当を分け、次の作業へと渡していきます。時間がかかる担当の場所があると、そこで折り紙がたまってしまう事になります。

秀和システムより「マルチコアCPUのための並列プログラミング―並列処理&マルチスレッド入門」が出ていますが、上記の並列についてWindows、Linuxについてのソースコードも含め、書かれているので参考になると思います。

少し内容が古くなっており、WindowsVista以降に入った条件変数のAPIについて書かれていないなどがありますが、異種混合CPUや、OpenMPなどのライブラリについても書かれており、入門には良いのではないかと思います。


6.プロセスとスレッドとメモリの関係と実験


並列で高速化する際に難しいのがメモリの扱い方です。 CPUからメモリに書き込みをする際に同時に書き込みをしないようにする排他制御が必要です。例え、データ並列化の場合にソフトウェア的にソースコードでは排他制御が必要ないとしても、ハードウェア的にはCPUとメモリの間のキャッシュがあり、データをある程度まとまった固まりで扱おうとするため、その事も考慮する必要があると思われます。その点では元々メモリが分かれているプロセスの方がスレッドよりはプログラミングの考慮は少なく済むかもしれません。

スレッドにおいて以下のようなプログラムで実験をしてみました。

#include <stdio .h>
#include <stdlib .h>
#include <memory .h>
#include <pthread .h>

#define MEMSIZE (1024L * 1024L * 1024L)

void mem_init(void *mem)
{
        int *p;
        long i;

        p = (int *)mem;
        for (i = 0; i < MEMSIZE; i++) {
                *p = 0;
                p++;
        }
}

void mem_init1_1(void *mem)
{
        int *p;
        long i;

        p = (int *)mem;
        for (i = 0; i < MEMSIZE / 2; i++) {
                *p = 0;
                p+= 2;
        }
}

void mem_init1_2(void *mem)
{
        int *p;
        long i;

        p = (int *)mem + 1;
        for (i = 0; i < MEMSIZE / 2; i++) {
                *p = 0;
                p += 2;
        }
}

void mem_init2_1(void *mem)
{
        int *p;
        long i;

        p = (int *)mem;
        for (i = 0; i < MEMSIZE / 2; i++) {
                *p = 0;
                p++;
        }
}

void mem_init2_2(void *mem)
{
        int *p;
        long i;

        p = (int *)mem + MEMSIZE / 2;
        for (i = 0; i < MEMSIZE / 2; i++) {
                *p = 0;
                p++;
        }
}

int main(int argc, char *argv[])
{
        int *mem;
        int proc = 0;
        pthread_t pth;

        if (argc > 1) {
                proc = atoi(argv[1]);
        }
        mem = (int *)malloc(MEMSIZE * sizeof(int));
        if (mem == NULL) {
                printf("malloc error\n");
                return 1;
        }
        switch (proc) {
        case 0:
                mem_init(mem);
                break;
        case 1:
                pthread_create(&pth, NULL, (void *)mem_init1_1, mem);
                mem_init1_2(mem);
                pthread_join(pth, NULL);
                break;
        case 2:
                pthread_create(&pth, NULL, (void *)mem_init2_1, mem);
                mem_init2_2(mem);
                pthread_join(pth, NULL);
                break;
        defalut:
                break;
        }
        free(mem);
        return 0;
}

単なる大量のメモリの初期化のプログラムです。引数が0番はスレッドを使わないもの。1番はキャッシュが効かないようにと作ったもの。2番はキャッシュが効くように作ったものです。sched_setaffinity関数でCPU指定をしていませんが、1番と2番はCPUを別に使っていました。

速度比較の結果は、user timeが0番は0.350s程度、1番が0.500s程度、2番が0.375s程度で、速度順は 0 > 2 > 1 でした。初期化程度ではスレッドなど他のオーバーヘッドなどの方が大きく、あまり意味のないテストだったかもしれませんが、1番が一番遅い結果となりました。マルチCPUの処理は思ったように速度をあげるのが難しいのは確かです。

0 件のコメント:

コメントを投稿