LoginSignup
15
15

More than 5 years have passed since last update.

sharedの話

Posted at

本日はsharedについて解説いたします。
sharedに関する資料は乏しく、dmdの現在の挙動や公式フォーラムでの僅かな議論を参考にすることでしか理解に近づくことはできないでしょう。
今回まとめた記事についても、私がおそらくこうだろうと思うものをまとめたものですので、その内容を100%保証するものではありません。
それでもよろしければ読み進めていただけると光栄です。

sharedって何に役立つの?

std.concurrency が導入されてからsharedの利用価値は増加しました。
というのも、std.concurrencyspawnsendなど数多くのAPIがshared型の引数を要求するからです。
(正確には hasUnsharedAliasing ではない型を要求します。shared型やimmutable型がこれに含まれます)

では shared とは一体何なのか。

sharedは主にマルチスレッドや割り込み処理(組み込み)を持つプログラムの開発をターゲットとした言語仕様です。
共有資源をイイカンジに扱うことができるようになります。

マルチスレッドや割り込み処理での共有資源の取り扱いはバグの温床です。
ちゃんとした設計ができていなかったり
共有資源を無視してガンガン適当にアクセスしまくってると
ほぼ確実に意味不明なバグで憤死することになります。

たとえば
「トイレ」というデータがあったとしましょう。
このデータを使って、あなたが何かしらの処理を行うことを考えてみます。
さて、ここに誰かがやってきて、同じようにデータを使って処理をしようとするわけです。
このデータ。鍵とかかけずに扱っていたらどうなりますか?
貴方が処理しているところに、そんなのお構いなしに割り込まれてしまうわけです。
どう思います? 嫌じゃありませんか?

ではどうするか。
当然です。鍵をかけます。
いや、ほかにも方法はあります。
誰かに割り込まれる前に、目にも留まらぬ速さで処理を終えてしまうというのも手ですね。
いずれにせよ。
処理の途中に割り込まれたらいやなケースでは注意が必要ということです。

鍵をかけないで処理してたらそりゃ憤死もします。

ところで余談ですが、この間コンビニでトイレを借りようとしたら
先客がガンバっている場面に直面してしまいました。
鍵は大事、絶対かけよう。

ともあれ、この「注意」をシステマチックに行うことを目的としたものが、sharedです。
特にマルチスレッドにおいてはそれは一般的にスレッドセーフと呼ばれる概念です。
以下、特にマルチスレッドに関して取り扱うこととします。
が、基本的に組み込みシステムにおける割り込み処理等でも同様のことが言えます。
どちらか好きな方で解釈してくださいますようお願いいたします。

sharedの種類

sharedの目的はマルチスレッドや割り込み処理等で必要となる「注意」をシステマチックに行うことです。
スレッドセーフ対応をsharedによってシステマチックに行うために、sharedは大きく分けて4つの機能を提供します。

  • 型としてのshared
  • ストレージクラスとしてのshared
  • 関数につくshared
  • 構造体/クラス/インターフェース/共用体につくshared

以下それぞれについて説明していきます。

型としてのshared

shared型というのはsharedなデータを形成するための型です。

shared() 変数名;

とすることで定義できます。
sharedなデータは複数のスレッドから参照されるデータ(共有資源)のことです。
そんなデータは取り扱いに際してこんな注意が必要。

  • 排他制御
  • アトミック操作
  • sharedデータを取り扱う関数による処理
  • 処理の優先度(割り込み処理の場合、場合によっては処理の優先度を考慮することでも正しく取り扱うことが可能です。詳しい解説は省略します。)

排他制御

これが先に説明した「トイレに鍵をかける」の例に相当します。
つまり、共有資源をひとつのスレッドからしか操作できなくする。
2つ目のスレッドが共有資源を操作したいときは、1つ目のスレッドの処理が終わるまで待ってもらう。

例)

import std.stdio, std.datetime;
import core.thread, core.sync.mutex;

void main()
{
    auto tg = new ThreadGroup;
    auto m  = new Mutex;
    auto sw = StopWatch(AutoStart.yes);
    // 共有資源
    shared a = 0;
    void foo(int* a, int i)
    {
        *a += i;
    }
    // 処理1
    tg.create( ()
    {
        foreach (i; 0..100)
        {
            // Mutexとsynchronizedを使ったのが一番楽かな
            // ここで処理2が synchronized を抜けるまで待つ
            synchronized (m)
            {
                // 共有資源にアクセス
                a++;
                // aのアクセスを相互排他しているため、キャストしても安全
                foo(cast(int*)&a, -i);
                // わかりやすくするため時間待ち。
                Thread.sleep(dur!"msecs"(10));
            }
        }
    });

    // 処理2
    tg.create( ()
    {
        foreach (i; 0..100)
        {
            // 処理1が synchronized を抜けるまで待つ
            synchronized (m)
            {
                // 共有資源にアクセス
                a--;
                // aのアクセスを相互排他しているため、キャストしても安全
                foo(cast(int*)&a, i);
                // わかりやすくするため時間待ち。
                Thread.sleep(dur!"msecs"(10));
            }
        }
    });

    tg.joinAll();
    assert(a == 0);
    writeln(sw.peek().msecs, "ms");
}

このように、安全に共有資源を取り扱うことができました。
上記では synchronized (m) とすることで、変数mのMutexを使用して相互排他を行なっています。
これで囲われた区間は単一のスレッドからしか実行することができなくなります。
D言語では、排他制御を行うためのモジュールは core.sync.* にまとめられています。

アトミック操作

これが先に説明した「トイレで目にも留まらぬ速さで用を足す」例に相当します。
実際には速さは関係ないわけですが。
重要なのは
アトミック操作を行なっている最中は、他の処理からは途中の状態が観測できない
ということです。

アトミック操作を行うためのモジュールは、 core.atomic にまとめられています。

import core.thread, core.atomic;

void main()
{
    auto tg = new ThreadGroup;
    // 共有資源
    shared a = 0;
    // 処理1
    tg.create( ()
    {
        foreach (i; 0..10000000)
        {
            // 共有資源をアトミック操作
            // aの値を「読んで」「+iして」「更新する」操作
            a.atomicOp!"+="(i);
            // 以下だとアトミック操作ではないので失敗する場合がある
            //a += i;
        }
    });

    // 処理2
    tg.create( ()
    {
        foreach (i; 0..10000000)
        {
            // 共有資源をアトミック操作
            // aの値を「読んで」「-iして」「更新する」操作
            a.atomicOp!"-="(i);
            // 以下だとアトミック操作ではないので失敗する場合がある
            //a -= i;
        }
    });

    tg.joinAll();
    assert(a == 0);
}

もちろん
アトミック操作を複数回行う場合
アトミック操作とアトミック操作との間に他の処理が割り込む可能性があるので注意。

具体的にはこんなケースでは注意です。

import core.thread, core.atomic;

void main()
{
    auto tg = new ThreadGroup;
    // 共有資源
    shared a = 0;
    shared b = 0;
    // 処理1
    tg.create( ()
    {
        foreach (i; 0..10000000)
        {
            a.atomicOp!"+="(i);
            b.atomicOp!"+="(i);
            // 下記assert文は成立しない場合がある
            assert(a.atomicLoad() == b.atomicLoad());
        }
    });

    // 処理2
    tg.create( ()
    {
        foreach (i; 0..10000000)
        {
            a.atomicOp!"-="(i);
            b.atomicOp!"-="(i);
            // 下記assert文は成立しない場合がある
            assert(a.atomicLoad() == b.atomicLoad());
        }
    });

    tg.joinAll();
    assert(a == 0);
}

あと、ロックフリーとかの単語で検索するとアトミック操作について詳しくなれるでしょう。

sharedデータを取り扱う関数による処理

上記排他制御やアトミック処理などを駆使し、スレッドセーフ性を完全に考慮した処理をまとめて、関数にするという手もあります。
その関数の引数でshared型を使えば良いわけです。
すでに紹介した atomicOp などはまさにこのような関数となっていますね。

無論ですが、注意点はアトミック操作と同様です。

特に関数の場合は、引数に2つのsharedデータを渡す場合などにもデータアクセスの順番が影響する場合がありますので注意です。

ストレージクラスとしてのshared

sharedストレージクラスは、大きく分けて

  • 変数の型をshared型にする
  • グローバル変数や静的変数(関数内のstatic変数)の場合、スレッドを越えてアクセスできるようにする

があります。

変数の型をshared型にする

sharedストレージクラスの変数の型は、明示の如何を問わずshared型になります。

shared int* a;

上記のaは shared(shared(int)*) 型と同じ型になります。

ちなみに、逆にhead-shared(型の一番外側がshared)の場合は、自動的にsharedストレージクラスになります。
これはconstやimmutableと同じ挙動ですね。

shared(int*) b;

上記のbは、sharedストレージクラスです。

グローバル変数や静的変数をスレッドを越えてアクセスできるようにする

通常のグローバル変数や静的変数は、デフォルトでTLS(Thread Local Storage)の領域にデータを配置します。
TLSというのは、スレッドごとに同じ変数に違うデータを割り振るもの。
つまり、スレッドを越えて同じデータにアクセスすることができなくするためのものです。

逆に、sharedを使うということは、複数のスレッドから触られる可能性のある変数ということ。
つまり、TLSには配置されず、どのスレッドからでもデータにアクセスすることができるようになります。

import std.stdio;
import core.thread, core.sync.barrier;

int a;         // TLS(非共有資源)
shared int b;  // 共有資源

void main()
{
    auto tg = new ThreadGroup;
    auto barrier = new Barrier(2);
    a = 10;
    b = 10;
    // T1
    tg.create( ()
    {
        barrier.wait();   // ① T2が①でbarrier.wait()するまで待機

        assert(a == 0);   // T1起動前に a は10で初期化されているけどTLSなので関係ない
        writeln("T1: a = ", a);
        assert(b == 10);  // T1起動前に b は10で初期化されている
        writeln("T1: b = ", b);

        barrier.wait();   // ② T2が②でbarrier.wait()するまで待機

        barrier.wait();   // ③ T2が③でbarrier.wait()するまで待機

        assert(a == 0);   // T2で a += 100 されたとしても、TLSだから関係ない
        assert(b == 110); // T2で b += 100 されてるので、10+100で110になっている
        a += 100;
        b += 100;
        assert(a == 100);
        assert(b == 210);
        writeln("T1: a = ", a);
        writeln("T1: b = ", b);
    });
    // T2
    tg.create( ()
    {
        assert(a == 0);   // T2起動前に a は10で初期化されているけどTLSなので関係ない
        writeln("T2: a = ", a);
        assert(b == 10);  // T2起動前に b は10で初期化されている
        writeln("T2: b = ", b);

        barrier.wait();   // ① T1が①でbarrier.wait()するまで待機

        barrier.wait();   // ② T1が②でbarrier.wait()するまで待機

        a += 100;
        b += 100;
        assert(a == 100);
        assert(b == 110);
        writeln("T2: a = ", a);
        writeln("T2: b = ", b);

        barrier.wait();   // ③ T1が③でbarrier.wait()するまで待機
    });
    tg.joinAll();
    assert(a == 10);      // T1やT2で変更されているけど、TLSなので関係ない
    writeln("T0: a = ", a);
    assert(b == 210);     // T1で+100, T2で+100されているので210
    writeln("T0: b = ", b);
}

関数につくshared

クラスや構造体、共用体、インターフェースのメンバ関数にconstやimmutableと同じようにsharedをつけることができます。
すでに定義してある関数にsharedがついている場合、その関数は処理中のスレッドセーフが保証されています。
すなわち、この関数はいついかなるタイミングでどのスレッドがコールしたとしても、動作を破綻させることなく正しい結果をもたらすことが約束されています。

逆に、shared関数を定義する場合は、上記動作を保証しなければなりません。
注意として、sharedがついているからといってsharedが排他制御やアトミック操作等何かしてくれるわけではありません。
プログラマ自身の手によってスレッドセーフを保証するような実装に仕上げる必要があります。

import core.thread, core.sync.mutex;

class A
{
    int a;
    int b;
    Mutex m;
    this() { m = new Mutex; }

    void foo(int i) shared
    {
        // thisはshared型に見える
        static assert(is(typeof(this) == shared));
        // 当然thisから参照されるaもshared型に見える
        static assert(is(typeof(a) == shared));
        // スレッドセーフはプログラマ自身が保証する必要がある
        synchronized (m)
        {
            // アトミック操作ではダメなケース。
            // →排他制御でスレッドセーフを保証する
            a += i;
            b += i;
            assert(a == b);
        }
    }
    void bar(int i) shared
    {
        // スレッドセーフはプログラマ自身が保証する必要がある
        synchronized (m)
        {
            a -= i;
            b -= i;
            assert(a == b);
        }
    }
}

void main()
{
    auto tg = new ThreadGroup;
    // shared型じゃないとfoo/barは呼べない
    auto a = new shared A;
    // T1
    tg.create( ()
    {
        // fooはsharedメンバ関数なので、スレッドセーフが保証される
        foreach (i; 0..1000000)
            a.foo(i);
    });
    // T2
    tg.create( ()
    {
        // barはsharedメンバ関数なので、スレッドセーフが保証される
        foreach (i; 0..1000000)
            a.bar(i);
    });
    tg.joinAll();
}

もちろん、こうして定義された関数についても同様の注意点はあります。
sharedメンバ関数もアトミック操作と同様に、関数処理中しかスレッドセーフを保証していません。
つまり、呼び出しと呼び出しの間に関してのスレッドセーフは呼び出す側が担保することになるので注意しましょう。

要するに考え方としては、前述した「sharedデータを取り扱う関数による処理」同様です。
第一引数にshared型のインスタンスが渡されると考えるとわかりやすいかと思います。

class A
{
    void foo(int b) shared { /* ... */ }
}

上記クラスAのメンバ関数fooは、以下のフリー関数fooと同じ考え方ができます。

class A{}
void foo(shared A a, int b) { /* ... */ }

最近、git-HEADでは void delegate() shared のような、デリゲートにもこの関数型に対するsharedが許容されるようになりました。
デリゲートでも同様のことが言え、その処理中はスレッドセーフが保証されます。

class A
{
    void foo(int b) shared { /* ... */ }
}

auto a = new A;
void delegate(int) shared dg = &a.foo;

delegateが入ってくると型のところで混乱してしまうかもしれません。
この関数につくsharedは通常の型に対するsharedやストレージクラスに対するsharedとは異なるものですので注意しましょう。
下記の型はすべて異なる型を形成します。

unittest
{
    shared(void delegate(int))*        dg1;
    shared void delegate(int)*         dg2;
           void delegate(int) shared*  dg3;
    shared(void delegate(int) shared)* dg4;
    shared void delegate(int) shared*  dg5;

    // 全部違う型
    static assert(!is(typeof(dg1) == typeof(dg2)));
    static assert(!is(typeof(dg1) == typeof(dg3)));
    static assert(!is(typeof(dg1) == typeof(dg4)));
    static assert(!is(typeof(dg1) == typeof(dg5)));
    static assert(!is(typeof(dg2) == typeof(dg3)));
    static assert(!is(typeof(dg2) == typeof(dg4)));
    static assert(!is(typeof(dg2) == typeof(dg5)));
    static assert(!is(typeof(dg3) == typeof(dg4)));
    static assert(!is(typeof(dg3) == typeof(dg5)));
    static assert(!is(typeof(dg4) == typeof(dg5)));
}

sharedを取り扱う関数に関する注意事項

このようなsharedを扱う関数の中の一部の関数では、引数に指定したデータへのアクセスが別のスレッドにて行われる場合があります。
たとえばstd.concurrency.spawn等ですね。
spawnというのは、新しいスレッドを作ってそこに引数のデータを引き渡します。
別のスレッドで引数のデータが使われるので、この引数のデータも共有資源でなければなりません。
でも、何でもかんでも共有資源でなければならないかというとそうでもありません。
たとえばint型はデータそのもののコピーで受け渡す事ができ、スレッドセーフを確保することができます。
では何がマズイのかというと、UnsharedAliasingなデータ…つまり、共有資源でないデータへの参照。これがマズイ。
そのような性質を持つ関数を定義する場合には、問題となるデータを引数に指定できないようにしてやる配慮が必要です。
constraint-ifhasUnsharedAliasingというテンプレートを使って、引数の型をチェックするとよいでしょう。

しかしながら、全て共有資源に正しい手段でしかアクセスしていないからといって、スレッドセーフが保たれるとは限らないことに注意してください。
重ねて注意喚起しますが、共有資源のアクセス自体をアトミック操作やsharedを扱う関数で行ったとしても、アクセスとアクセスの間にはスレッドセーフが保証されていないことに気をつけましょう。

構造体/クラス/インターフェース/共用体につくshared

構造体やクラス、インターフェース、共用体にもsharedを付けることができます。
変数の宣言時、型にshraedを付けなければshared型として扱われませんが、sharedを付けなければまともに扱うことは出来ないでしょう。

メンバ変数は自動的にshared型となり、そのデータはすべて共有資源となります。
これらのデータは、メンバ関数からもスレッドセーフが保たれるように取り扱わなければなりません。
また、メンバ関数はすべて自動的にsharedメンバ関数となります。

shared class A
{
    int a;
    void foo() { }
}
static assert(is(typeof(A.init.a) == shared));
static assert(is(typeof(A.foo) == shared));

まとめ

sharedが提供する機能は4つあるので上手に使い分けましょう。

  • 型としてのshared→共有資源を型で表現できます
  • ストレージクラスとしてのshared→TLSと共有資源を使い分けられます
  • 関数につくshared→共有資源を扱ってスレッドセーフを保証します
  • 構造体/クラス/インターフェース/共用体につくshared→全部のメンバがsharedになる

shared型のデータ(共有資源)を上手に扱う方法は

  • 排他制御
  • アトミック操作
  • sharedデータを取り扱う関数による処理(sharedメンバ関数含む)

その時の注意点は

  • sharedを扱う関数の処理と処理の間はスレッドセーフが保証されない
  • 関数の引数が別のスレッドでアクセスされる場合は!hasUnsharedAliasingの必要あり

最後に

sharedは現在いろいろと発展途上で、druntimeやPhobosですら対応しているものはごく僅かです。
おもいっきりsharedであるべき Mutex.lock とかですら shared 対応していません。
最近は私もsharedを使ったプログラムを書いていますが、まだまだcastだらけのあまり綺麗ではないプログラムになってしまいます。
誰も使わないから形だけあることになっている使いにくいsharedというのが現状です。
使わなければ改善はありません。
皆様もちょっと無理して使ってみて、公式へとフィードバックをかけ、よりsharedを使いやすくして行きませんか?
議論できる人があんまりいなくて寂しいです。
このページの内容についても、ここってこういうこと? とか、ここは違うんじゃないか? とか、ご意見があればガンガンコメントしてくださいね!

次は @ne_sachirou です。

P.S. 例が下品でスイマセン

15
15
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
15