暗黙的スレッディングと各プログラミング言語のスレッド管理を徹底解説
暗黙的スレッディング(Implicit Threading)とは
マルチスレッドアプリケーション開発における難しさに対処し、設計をより容易にするための有力なアプローチの一つが、スレッドの作成と管理をアプリケーション開発者からコンパイラやランタイムライブラリへ移譲することです。この手法は「暗黙的スレッディング(implicit threading)」と呼ばれ、現在広く普及しているトレンドとなっています。
暗黙的スレッディングとは、主にライブラリや言語機能を利用して、スレッド管理の詳細を開発者から隠蔽する仕組みのことです。最も代表的な暗黙的スレッディングのライブラリとして、C言語の文脈ではOpenMPが挙げられます。
OpenMP
OpenMPは、C・C++・FORTRANで記述されたプログラム向けのコンパイラディレクティブおよびAPIの集合体であり、共有メモリ環境における並列プログラミングをサポートします。OpenMPは「並列領域(parallel region)」として、並列実行可能なコードブロックを識別します。開発者はコード内の並列化したい箇所にコンパイラディレクティブを挿入するだけで、OpenMPランタイムライブラリがその領域を並列に実行してくれます。
以下のCプログラムは、printf()文を含む並列領域の直前にコンパイラディレクティブを記述した例です。
コード例
#include <omp.h>
#include <stdio.h>
int main(int argc, char *argv[]){
/* 逐次コード */
#pragma omp parallel{
printf("I am a parallel region.");
}
/* 逐次コード */
return 0;
}
実行結果
I am a parallel region.
OpenMPが#pragma omp parallelというディレクティブを検出すると、システムの処理コア数と同じ数のスレッドを生成します。つまり、デュアルコア環境では2つ、クアッドコア環境では4つのスレッドが作られます。その後、すべてのスレッドが同時に並列領域を実行し、各スレッドは並列領域を抜けると終了します。OpenMPにはループの並列化をはじめ、コード領域を並列実行するためのさまざまな追加ディレクティブが用意されています。
また、OpenMPは単純な並列化ディレクティブだけでなく、開発者に複数段階の並列度の選択肢も提供します。たとえば、スレッド数を手動で設定したり、データがスレッド間で共有されるのか、あるいは特定のスレッド専用(private)なのかを指定したりできます。OpenMPはLinux、Windows、Mac OS X向けの多くのオープンソースおよび商用コンパイラで利用可能です。
Grand Central Dispatch(GCD)
Grand Central Dispatch(GCD)は、AppleのMac OS XおよびiOS向けの技術で、C言語への拡張、API、ランタイムライブラリを組み合わせたものです。これにより、開発者は並列実行したいコードの断片を簡単に指定できるようになります。OpenMPと同様に、GCDもスレッド管理の大部分を自動的に引き受けてくれます。
GCDは、C/C++言語への拡張機能である「ブロック(block)」を採用しています。ブロックとは、自己完結した作業単位のことであり、中括弧 { } の直前にキャレット ^ を挿入することで記述できます。以下はシンプルなブロックの例です。
{
^printf("This is a block");
}
GCDはブロックをディスパッチキュー(dispatch queue)へ投入することで、実行をスケジューリングします。GCDがキューからブロックを取り出すと、自身が管理するスレッドプールの中から利用可能なスレッドへそのブロックを割り当てます。ディスパッチキューには「シリアルキュー」と「同時実行キュー(concurrent queue)」の2種類があります。
- シリアルキュー: ブロックはFIFO順で取り出され、あるブロックの実行が完了するまで次のブロックは取り出されません。各プロセスは固有のシリアルキュー(メインキュー)を持ち、開発者はプロセスローカルな追加のシリアルキューを作成することもできます。シリアルキューは、複数タスクの逐次実行を保証したい場合に有効です。
- 同時実行キュー: ブロックは同じくFIFO順で取り出されますが、複数のブロックを一度に取り出せるため、複数のブロックを並列に実行できます。システム全体で3つの同時実行キューが用意されており、優先度(低・標準・高)によって区別されます。重要度の高いブロックほど、高優先度のキューに配置すべきです。
以下のコードは、標準優先度の同時実行キューを取得し、dispatch_async()関数を使ってブロックをキューへ投入する例です。
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{ printf("This is a block."); });
内部的には、GCDのスレッドプールはPOSIXスレッドで構成されています。GCDはこのプールを積極的に管理し、アプリケーションの需要とシステムの容量に応じてスレッド数を動的に増減させます。
オブジェクトとしてのスレッド
一方、従来のオブジェクト指向言語の多くは、スレッドをオブジェクトとして扱う明示的なマルチスレッドサポートを備えています。こうした言語では、Threadクラスを継承するか、対応するインターフェースを実装する形でクラスを記述します。このスタイルは明示的なスレッド管理コードを書く点でPthreadsのアプローチに似ていますが、クラスによる情報のカプセル化や充実した同期機能のおかげで、実装作業は格段にやりやすくなっています。
Javaのスレッド
JavaではThreadクラスとRunnableインターフェースを利用できます。いずれの場合も、スレッドのエントリポイントとなるpublic void run()メソッドを実装する必要があります。オブジェクトのインスタンスを生成したら、start()メソッドを呼び出すことでスレッドが起動します。Pthreadsと同様、スレッドの開始は非同期であるため、実行のタイミングは非決定的です。
Pythonのスレッド
Pythonにもマルチスレッドのための2つのメカニズムが用意されています。ひとつはPthreadsスタイルに近い方法で、関数名をライブラリ関数thread.start_new_thread()へ渡します。この方法は手軽ですが、スレッドを開始した後にjoinや終了を行う柔軟性に欠けます。より柔軟なのは、threadingモジュールを使ってthreading.Threadを継承したクラスを定義する方法です。Javaのアプローチと同様に、クラスにはスレッドのエントリポイントとなるrun()メソッドを持たせます。このクラスからオブジェクトを生成すれば、スレッドを明示的に開始し、後からjoinすることも可能です。
言語設計としての並行性
より新しいプログラミング言語の一部は、並行実行を前提とした設計を言語仕様そのものに組み込むことで、競合状態(race condition)の発生を防いでいます。たとえば、Goは手軽な暗黙的スレッディング機構(ゴルーチン)と、明確に定義されたメッセージパッシング通信である「チャネル」を組み合わせています。RustはPthreadsに似た明示的なスレッド方式を採用しつつ、開発者が追加の手間をかけなくても済む非常に強力なメモリ保護機構を備えているのが特長です。
ゴルーチン(Goroutines)
Go言語には、非常にシンプルな暗黙的スレッディング機構が組み込まれています。関数呼び出しの前にgoキーワードを置くだけで、新しいスレッドが起動します。新しく生成されたスレッドには、メッセージパッシング用チャネルへの参照が渡されます。メインスレッドは success := <-messages のようにチャネルからの受信(ブロッキング読み取り)を待ち、ユーザーが正解の「7」を入力すると、キーボード監視スレッドがチャネルへ書き込み、メインスレッドの処理が先へ進みます。
チャネルとゴルーチンはGo言語の中核的な構成要素であり、「ほとんどのプログラムはマルチスレッドになるだろう」という前提のもとで設計されました。この設計判断によって開発モデルが簡素化され、スレッド管理やスケジューリングの責務を言語自体が担えるようになっています。
Rustの並行性
近年登場した言語として注目されているのがRustです。並行性はRustの中心的な設計要素の一つとなっています。以下の例は、thread::spawn()で新しいスレッドを生成し、後からjoin()を呼び出して完了を待つコードです。thread::spawn()の引数のうち || 以降の部分は「クロージャ」と呼ばれ、無名関数と考えることができます。ここでは子スレッドが変数aの値を出力します。
コード例
use std::thread;
fn main() {
/* ミュータブルな変数aを7で初期化 */
let mut a = 7;
/* 新しいスレッドを生成 */
let child_thread = thread::spawn(move || {
/* スレッドを1秒スリープさせ、aを出力 */
a -= 1;
println!("a = {}", a)
});
/* メインスレッドでaを変更して出力 */
a += 1;
println!("a = {}", a);
/* スレッドをjoinして再度aを出力 */
child_thread.join();
}
しかしこのコードには、Rustの設計思想を象徴する重要なポイントが隠れています。新しいスレッド(クロージャ内のコード)の中にある変数aは、コードの他の部分にあるaとは別個の変数なのです。Rustは「所有権(ownership)」と呼ばれる非常に厳格なメモリモデルを強制しており、複数のスレッドが同一のメモリにアクセスすることを防ぎます。この例では、moveキーワードによって、生成されたスレッドが自分専用のaのコピーを受け取ることが示されています。そのため、2つのスレッドのスケジューリングがどうであれ、メインスレッドと子スレッドは互いのaへの変更に干渉できません。両者は独立したコピーを持ち、同一メモリへのアクセスを共有することがそもそも不可能になっているのです。この仕組みにより、データ競合はコンパイル時に排除されます。
-
【解決済み】Windows 10/11で印刷キューのジョブが削除できないときの対処法
プリンターが正常に動作しないエラーは多くのユーザーにとってよくある悩みですが、その中でも「Windows 10やWindows 11で印刷キューからドキュメントが削除できない」というトラブルに困っている方も少なくありません。 この記事の内容 印刷キューが削除できない問題の概要 印刷ジョブがキューに滞留する原因 Windows 11/10で印刷キューをクリアする4つの方法 印刷キューが削除できない問題の概要 Windows 10やWindows 11では、いつ印刷ジョブがキューに滞留してもおかしくありません。よくあるケースとしては以下のようなものがあります。 1. ドキュメントを印刷
-
C言語の型変換入門:暗黙的変換と明示的変換(キャスト)の違いを徹底解説
あるデータ型を別のデータ型へ変換することを「型変換(type conversion)」と呼びます。C言語における型変換には、主に以下の2種類があります。 暗黙的な型変換(implicit conversion) 明示的な型変換(explicit conversion) 暗黙的な型変換とは 演算対象(オペランド)同士のデータ型が異なる場合、コンパイラが自動的に行う型変換です。 プログラマが特に指示しなくても、小さいデータ型から大きいデータ型への変換が自動的に実行されます。これを「型の昇格」とも呼びます。 int i,x; float f; double d; long int l;