🎣

借りっぱなしのアリエッティ(C++ の semaphore について)

2022/11/01に公開

世の中にはレンタルしたものをそのまま購入できるサービスがある。使ってみて気に入ったら返却せずに購入できるというサービスだ。届くものはいわば使い古しの中古品なので、おそらく市場価格よりも安く購入できるのだろう。ただ、そうは言っても1番最初の人には新品が届くわけで、最初の人に買われまくったら店側は損してしまうんじゃないかと心配になってしまう。
この「返却と購入を選べるシステム」をソフトウェア的な観点でとらえると、所有権の管理が少々面倒だなと感じてしまうのは職業病だろうか?

日常生活において、入場制限や個数制限のように何らかの数に応じた制限をかけることがよくある。特に退出や返却によって回復するものは管理対象になりやすく、ソフトウェアの世界でもそれは同じだ。
C++20 では共有リソースの管理に適した semaphore が標準ライブラリに追加された。今回はこの semaphore に注目してみよう。

counting_semaphore の基本

counting_semaphore は内部にカウンタを1つ持つ。そして、各スレッドがこの counting_semaphore に対してできることは以下の2つ。

  • カウンタが1以上になるまで待ち、1以上になったらカウンタを1減らして動き始める。
  • 任意の数だけカウンタを増やす。(普通は1増やす)

counting_semaphore は主に共有リソースへの同時アクセス数を制限する目的で使われる。
例えば最大で10台停められる駐車スペースのゲートを想像すると分かりやすいだろう。カウンタ、つまり残りの駐車スペースがゼロになったらゲートの前で待たされて、出庫する車がカウンタを増やすことでゲートを通過できるようになる。

30台の車が10台の駐車スペースに殺到して、1秒だけ駐車するというシュールなサンプルがコチラ。

#include <iostream>
#include <thread>
#include <vector>
#include <semaphore>
#include <chrono>

int main()
{
    constexpr int NUM_CARS = 30;
    std::counting_semaphore parking_lot(10); // 10台停められる

    // 車
    std::vector<std::thread> ths; ths.reserve(NUM_CARS);
    for (int i = 0; i < NUM_CARS; ++i) {
        ths.emplace_back(std::thread([&]() {
            parking_lot.acquire(); // 入庫
            std::cout << ".";
            std::this_thread::sleep_for(std::chrono::seconds(1)); // 駐車
            parking_lot.release(); // 出庫
        }));
    }

    // メインスレッドは待つだけで何もしない
    for (auto& th : ths)
        th.join();
}

これを実行すると、30台の車(1台 = 1スレッド)が10台ずつ駐車していく様子を確認できる。

latch の解説記事では、condition_variable を使うよりも latch の方が簡単に書ける例を挙げたが、counting_semaphore の場合も同様に condition_variable を使うよりも楽に書ける。上の例には mutex も台数を管理する変数も登場しない。condition_variable を使って書く場合はこれら3点セットが必須の要素となるため、どうしてもコード量が増えてしまう。

latch も semaphore もカウンタを内部に持つスレッド同期機構だが、latch はカウンタがゼロになるまで待つ、semaphore はカウンタがゼロの間は待つという、ある意味対となる機能ともいえるだろう。

binary_semaphore と mutex

<semaphore> ヘッダには counting_semaphore が持つカウンタの最大値が1である特別バージョンとして binary_semaphore も用意されている。カウンタの最大値が1、つまりカウンタの値は0か1のどちらかとなる。二値に固定することで counting_semaphore の最大値を1にした時よりも高速な内部実装が期待できるし、N4861 32.7/2 では以下のように "should be" が使われていて、まあまあ断定的に書かれていたりもする。

A binary semaphore should be more efficient than the default implementation of a counting semaphore with a unit resource count.

ところで、二値の semaphore は mutex とは何が違うのだろう? 以下のコードを見ても、やれることに差があるとは思えない。

#include <iostream>
#include <thread>
#include <semaphore>
#include <mutex>

int main()
{
    std::mutex mtx;
    mtx.lock();
    std::binary_semaphore bsem(1);
    bsem.acquire();

    // サブスレッド
    std::thread th([&]() {
        mtx.lock(); // メインスレッドが unlock するまで待つ
        std::cout << "locked" << std::endl;
        mtx.unlock();

        bsem.acquire(); // メインスレッドが release するまで待つ
        std::cout << "acquired" << std::endl;
        bsem.release();
    });

    // メインスレッド
    std::this_thread::sleep_for(std::chrono::seconds(1));
    mtx.unlock();
    std::this_thread::sleep_for(std::chrono::seconds(1));
    bsem.release();

    th.join();
}

実は mutex と binary_semaphore の違いは所有権の有無にある。

  • mutex はスレッドがロック状態を所有するようなイメージ。lock したスレッドが責任をもって unlock しなければならない。他のスレッドは unlock できない。
  • binary_semaphore の acquire は単に別のスレッドが release するのを待つだけで、特に何かを所有するわけではない。acquire したスレッドが release せずに、別のスレッドが代わりに release してもよい。借りっぱなしが許されるのである。

mutex はクリティカルセクションという部屋に入って鍵をかけ (lock)、保護エリア内で作業を終えたら鍵を開けて (unlock) 部屋を出るイメージ。

一方、semaphore は池に魚を放流 (release) し続けている人を思い浮かべるとよい。その魚を釣りあげようと (acquire) 釣り人が待ち構えているという構図[1]だ。池の魚を1匹だけに限定したものが binary_semaphore で、counting_semaphore なら2匹以上の魚を放流できる。もちろん放流する人と釣り人は同じ人でも構わない。同じ人に限定したければ mutex を使うことになる。

一般に、厳しい制限がかかるほど間違いが少なくなる。mutex が使えるケースならば semaphore も使えるが、その場合はより制限の厳しい mutex を使う方が安全なコードになるだろう。

参考

脚注
  1. Producer–consumer problem ↩︎

Discussion