🚀

並行処理の世界 - fork()、Thread、goroutine、Virtual Thread、Tokioの比較

に公開

並行処理の世界

こんにちは!本記事では、プログラミングにおける並行処理の様々な手法について、初心者の方にもわかりやすく解説します。

プログラミングの世界では「同時に複数のことを処理したい」という状況がよくあります。例えば、複数の音声ファイルを変換したり、複数のWebリクエストを処理したりする場合などです。この「並行処理」を実現するための方法は様々あり、それぞれに特徴があります。

並行処理とは何か

並行処理とは、複数の処理を同時に、あるいは見かけ上同時に実行することです。一般的なPCは複数のCPUコア(処理装置)を持っているため、実際に複数の処理を同時に行うことができます。

今回は以下の並行処理手法を比較します。

  1. Unix系OSのfork()
  2. Windowsスレッド
  3. Goプログラミング言語のgoroutine
  4. Java 21の仮想スレッド
  5. RustのTokio

実際の例として、MP4形式の音声ファイルをMP3形式に変換する処理を考えてみましょう。

1. fork() - Unixの伝統的な並行処理

Unixシステムでは、fork()という関数を使って実行中のプログラム(プロセス)のコピーを作成できます。

// C言語によるfork()の例
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid < 0) {
        // fork失敗
        printf("エラー: forkに失敗しました\n");
    } else if (pid == 0) {
        // 子プロセス
        printf("子プロセス: 私のIDは %d です\n", getpid());
    } else {
        // 親プロセス
        printf("親プロセス: 私のIDは %d で、子プロセスのIDは %d です\n", getpid(), pid);
    }
    
    return 0;
}

特徴

  • 完全に独立したプロセスが作成される
  • メモリ空間が完全に分離されている
  • プロセス間でデータを共有するには特別な仕組みが必要
  • リソース(メモリなど)の使用量が多い

Node.jsでのfork

Node.jsではClusterモジュールを使うことで、Windowsを含む様々なOSでfork的な動作を実現できます。

// Node.jsのClusterモジュール例
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`マスタープロセス ${process.pid} 実行中`);

  // CPUコアの数だけワーカーを起動
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  // ワーカープロセスはHTTPサーバーを共有できる
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello World\n');
  }).listen(8000);

  console.log(`ワーカー ${process.pid} 起動完了`);
}

2. Windowsスレッド - 軽量な並行処理単位

Windowsスレッドは、同じプロセス内で実行される処理の単位です。同じメモリ空間を共有するため、データのやり取りが容易になります。

// C++によるWindowsスレッドの例
#include <windows.h>
#include <iostream>

DWORD WINAPI ThreadFunction(LPVOID lpParam) {
    int threadId = *((int*)lpParam);
    std::cout << "スレッド " << threadId << " 実行中\n";
    return 0;
}

int main() {
    HANDLE hThread;
    DWORD threadId;
    int param = 1;
    
    hThread = CreateThread(
        NULL,                   // セキュリティ属性
        0,                      // スタックサイズ(デフォルト)
        ThreadFunction,         // スレッド関数
        &param,                 // スレッド関数の引数
        0,                      // 作成フラグ
        &threadId               // スレッドID
    );
    
    if (hThread) {
        WaitForSingleObject(hThread, INFINITE);  // スレッド終了を待つ
        CloseHandle(hThread);  // ハンドルを閉じる
    }
    
    return 0;
}

特徴

  • 同一プロセス内での並行実行
  • メモリを共有するため、データのやり取りが容易
  • fork()よりも軽量
  • 数百程度のスレッドは作成可能

3. Goのgoroutine - 超軽量な並行実行単位

Goプログラミング言語では、goroutineという非常に軽量な実行単位を使って並行処理を行います。

// Goのgoroutine例
package main

import (
    "fmt"
    "time"
)

func sayHello(id int) {
    fmt.Printf("goroutine %d: こんにちは\n", id)
}

func main() {
    for i := 0; i < 5; i++ {
        go sayHello(i)  // goroutineを起動
    }
    
    // メインgoroutineが終了しないように少し待つ
    time.Sleep(1 * time.Second)
}

特徴

  • 非常に軽量(数KB程度のメモリから開始)
  • 数千〜数万のgoroutineを同時に実行可能
  • チャネルという仕組みでデータの安全なやり取りが可能
  • シンプルな構文で使いやすい

4. Java 21の仮想スレッド - 新世代の軽量スレッド

Java 21から導入された仮想スレッドは、少数のOSスレッドで多数の軽量スレッドを効率よく実行する仕組みです。

// Java 21の仮想スレッド例
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExample {
    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                final int id = i;
                executor.submit(() -> {
                    System.out.println("仮想スレッド " + id + " 実行中");
                    return null;
                });
            }
            // すべてのタスクが完了するのを待つ
            System.out.println("全タスク投入完了、実行中...");
        } // try-with-resourcesでexecutorは自動的にシャットダウンされる
    }
}

特徴

  • 従来のJavaスレッドよりもはるかに軽量
  • 数十万の仮想スレッドを同時に作成可能
  • 既存のJavaコードとの互換性が高い
  • I/O操作中に効率よく別のスレッドに切り替え

5. RustのTokio - 高性能な非同期ランタイム

Rustプログラミング言語では、Tokioという非同期ランタイムが広く使われています。

// RustのTokio例
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    for i in 0..5 {
        tokio::spawn(async move {
            println!("タスク {} 実行中", i);
            sleep(Duration::from_millis(100)).await;
            println!("タスク {} 完了", i);
        });
    }
    
    // メインタスクが終了しないように少し待つ
    sleep(Duration::from_secs(1)).await;
}

特徴

  • 非常に効率的なメモリ使用
  • 数十万の非同期タスクを同時実行可能
  • コンパイル時の型チェックで多くのバグを事前に発見
  • ゼロコスト抽象化による高いパフォーマンス

実用例 - ffmpegによる音声ファイル変換

では、これらの並行処理手法を使って、複数のMP4ファイルをMP3に変換する実用的な例を見てみましょう。ここではGolangの例を示します(他の言語の完全なコードは省略します)。

package main

import (
    "fmt"
    "os/exec"
    "path/filepath"
    "strings"
    "sync"
    "time"
)

func convertToMP3(inputFile, outputFile string, wg *sync.WaitGroup) {
    defer wg.Done() // 関数終了時にWaitGroupのカウンタを減らす
    
    fmt.Printf("変換開始: %s\n", inputFile)
    
    // ffmpegコマンドを実行
    cmd := exec.Command("ffmpeg", "-i", inputFile, "-vn", "-ab", "128k", 
                        "-ar", "44100", "-y", outputFile)
    cmd.Stdout = nil
    cmd.Stderr = nil
    
    err := cmd.Run()
    if err != nil {
        fmt.Printf("変換失敗: %s - %v\n", inputFile, err)
        return
    }
    
    fmt.Printf("変換完了: %s\n", inputFile)
}

func main() {
    inputFiles := []string{
        "./input/video1.mp4",
        "./input/video2.mp4",
        "./input/video3.mp4",
    }
    
    var wg sync.WaitGroup
    
    startTime := time.Now()
    
    for _, inputFile := range inputFiles {
        // 出力ファイル名を決定
        baseName := filepath.Base(inputFile)
        outputFile := "./output/" + strings.TrimSuffix(baseName, filepath.Ext(baseName)) + ".mp3"
        
        wg.Add(1) // WaitGroupのカウンタを増やす
        go convertToMP3(inputFile, outputFile, &wg) // goroutineを起動
    }
    
    // すべてのgoroutineの終了を待つ
    wg.Wait()
    
    duration := time.Since(startTime)
    fmt.Printf("すべての変換が完了しました。所要時間: %.2f秒\n", duration.Seconds())
}

各手法の比較

以下の表で、各並行処理手法の特徴を比較してみましょう。

実装方法 メモリ使用量 CPUコア活用 リソース消費 実装の容易さ 同時実行数の上限 対応プラットフォーム 型安全性
Unix fork() 低(数十〜数百) Unix系のみ
Node.js Cluster 中(CPUコア数程度) クロスプラットフォーム
Windowsスレッド 中(数百程度) Windowsのみ
Golang goroutine 高(数千〜数万) クロスプラットフォーム
Java 21 Virtual Threads 高(数十万) クロスプラットフォーム
Rust Tokio 最低 高(数十万) クロスプラットフォーム

用途に応じた選択のポイント

各並行処理手法には長所と短所があるため、用途に応じて適切な手法を選ぶことが大切です。

I/O処理が多い場合

Webサーバーやファイル処理など、I/O待ち時間が多い場合には、Tokio、goroutine、仮想スレッドが特に効率的です。これらは少数のOSスレッドで多数の軽量スレッドを管理し、I/O待ち中に他のタスクに切り替えることができます。

CPU負荷が高い処理

画像・動画処理や科学計算など、CPU負荷が高い処理の場合には、実際に使用できるスレッド数はCPUコア数に制限されるため、どの手法でも上限のパフォーマンスは似たものになります。ただし、オーバーヘッドの違いから、Tokioやgoroutineが若干有利になることがあります。

開発の容易さ

開発速度を重視する場合は、Goのgoroutineが最も簡単に使えるでしょう。シンプルな構文と安全な並行処理モデルが特徴です。Node.jsも比較的シンプルに並行処理を実装できます。

セキュリティと安全性

セキュリティやメモリ安全性を重視する場合は、RustのTokioが最も優れています。コンパイル時に多くのバグを検出できる強力な型システムが特徴です。

初心者におすすめの選択肢

並行処理を学び始める方には、以下のアプローチをおすすめします

  1. まずはGolangのgoroutineから始めると良いでしょう。シンプルな構文と安全な並行処理モデルで、並行プログラミングの基本概念を理解しやすいです。

  2. Node.jsも比較的取っつきやすく、Webアプリケーション開発との親和性が高いため、Web開発を行っている方におすすめです。

  3. JavaやC#などのオブジェクト指向言語を既に使いこなしている方は、それらの言語のスレッドモデルから学ぶのも良いでしょう。特にJava 21の仮想スレッドは従来のJavaスレッドと比べて格段に使いやすくなっています。

  4. より深く理解したい場合は、RustとTokioに挑戦してみるのも良いでしょう。学習曲線は初期はなかなか進まず、ある時から急に理解が進む、というなかなかハードルが高い言語ですが、並行処理の本質的な課題と解決策について多くを学べます。

まとめ

並行処理は現代のプログラミングで非常に重要なテーマです。CPUのマルチコア化が進む中、効率的に計算資源を活用するためには並行処理の理解が不可欠になっています。

各並行処理手法にはそれぞれ特徴があり、用途や開発環境に応じて適切な選択をすることが大切です。特に初心者の方は、まずは比較的シンプルなGolangのgoroutineやNode.jsから始めると良いでしょう。

今回紹介した知識が皆さんのプログラミング学習の助けになれば幸いです。並行処理の世界は奥が深く、学び続けることで更に効率的で信頼性の高いプログラムを書けるようになります。ぜひ実際に手を動かして、並行処理の可能性を探ってみてください。

Discussion