DMD 2.077.0で行われたatomicLoadのBreaking changeについて少しだけ詳しい話


はじめに

2017年11月1日にD言語の公式コンパイラーであるdmd 2.077.0がリリースされました。

公式サイト ChangeLog: 2.077.0
https://dlang.org/changelog/2.077.0.html

今回は、この中に含まれるRuntime changesから個人的に色々ハマったcore.atomic.atomicLoadの変更内容についてです。

個人的には久しぶりにおいしい引っかかった破壊的変更だったので、同様に引っかかった方の手助けになれば幸いです。

TL;DR

  • sharedを使ったコードを書いていなければ大して影響はない
  • atomicLoadは、元々 shared(T) => T という変換を行っていた
    • でもそれダメなのでは?とツッコミが入って対応された
  • 今回、atomicLoadの戻り値が TailShared!(shared(T)) になった
  • TailShared(T)は以下のような動きをするテンプレート
    • Tのsharedを外した型UTに代入できるならUのalias(主にintなどの単純な値型)
    • Tが構造体なら、「Tの参照型のフィールドだけTailSharedで再帰的に変換した新しい構造体」のalias
    • それ以外はTのalias
  • クラスなどの場合、shared(T) => shared(T) という動作になるので戻り値の型が変わる
    • shared(T) objT に変換するには、 cast() obj と書けば良い
  • 構造体だと新しい型が返ってくるので、型を書いたT obj = atomicLoad(sharedObj);とかがエラーになる
    • TailSharedから構造体に変換する関数を作る必要がありそう
  • 実際に何がどう変換されるのかは、以下のテスト項目で網羅されているのでそちらで

atomicLoadとは

D言語には、スレッド間で共有する変数に対し明示的に型を修飾するためのsharedというキーワードがあります。

このsharedな変数から値を読みだすには、本来ロックやメモリバリアなどを駆使して適切なコードを書く必要があります。
これを一発でよろしく処理してくれるのが今回の atomicLoad 関数です。

これは core.atomic パッケージに含まれており、マルチスレッドな処理を書いていたりすると結構使いますが、そこまで気にして書くことはほとんどないかもしれません。

さて、shared自体の説明についてはまた長くなってしまうので、以下の記事を参照ください。
(5年前の内容がほぼ生きてる!)

変更理由

Change Logにもありますが、元々Bug Trackerのほうに報告が上がっており、それが変更理由となっています。

Issue 16230 - core.atomic.atomicLoad removes shared from aggregate types too eagerly
https://issues.dlang.org/show_bug.cgi?id=16230

意訳すると、「現状変数から値をロードするついでにshared全部外してるけど、ポインタ型のフィールドを含んでいる構造体とか、そのsharedまで無くすのはやりすぎでは?」という内容です。

たしかに、構造体そのものは値型としてコピーされるので良いですが、ポインタは大体共有されてますから、まずいですね。
要するに以下のケースで破綻します。

struct S { long* ptr; }

void main()
{
    shared(long) count = 0;
    shared S s1 = shared(S)(&count); // 別スレッドから使うイメージでポインタだけ共有
    shared S s2 = shared(S)(&count);

    import core.atomic: atomicLoad, atomicOp;

    // 2.076.xまでのコード
    S s = atomicLoad(s2); // 前はこれができていた。単にsharedが消えた。
    *s.ptr = 10; // でもこれ本当に大丈夫?
    pragma(msg, typeof(s.ptr)); // long*になる
    atomicOp!"+="(*s.ptr, 10); // 型がlongなのでできなかった

    // 2.077.0からのコード
    auto s = atomicLoad(s2); // 戻り値はTailShared!(shared(S))なのでautoで受けておく
    pragma(msg, typeof(s.ptr)); // shared(long)*になっており、Sではないことがわかる
    atomicOp!"+="(*s.ptr, 10); // 元来こちらが正しい操作方法
}

対処された方法

コーナーケースの通り、sharedな変数が参照している先が非sharedにできるかわからない、というのが根本的な問題です。
わからないのだから、そのままsharedにしておくしかありません。sharedを外すなら自己責任です。

で、実際どうなったかというと、構造体ならフィールドごとにshared残すかどうか見て、フィールドにアクセスできるプロキシっぽい構造体を返す仕組みになってます。
実装では、最近追加されたstatic foreachでゴリゴリproperty生やしてますね。黒魔術が加速している…

そして、参照型であるポインタやクラスのsharedは外さずそのままです。

結果として、

  1. shared(T)な構造体の場合、TailShared!(shared(T))という新しい構造体が返ってくる
  2. shared(T)なポインタやクラスなどの参照型の場合、shared(T)なオブジェクトがそのまま返ってくる
  3. intなどのプリミティブな値型の場合、sharedがなくなった値が取れる

という動作になって、1と2がBreaking changesだよ、ということです。

具体的な変換パターンは単体テストで確認してもらえればと思います。
https://github.com/dlang/druntime/blob/master/src/core/atomic.d#L99

できなくなったこと、ハマりポイント

構造体の場合

構造体Sがあるとき、shared(S)をatomicLoadに渡してSで受ける、という以下のパターンができません。

2.077.0でエラーになる例
struct S { }

void main()
{
    shared S ss;
    S us = atomicLoad(ss); // コンパイルエラー(TailShared!(shared(S))はSに代入できない)
}

これは個人的に該当しませんでした。大体autoで書いていればこれ自体は問題なく通るかと思います。

実際には、このあと他の関数に渡したりするとき、型が違うのでエラーになる、というパターンでしょうか?

その場合、愚直にTailShared!(shared(S)) => Sな変換をする関数を作ってしまうのが良さそうです。
実際にコンパイルして、エラーを一歩一歩解決していきましょう。

クラスなど参照型の場合

個人的にはこれに該当して半日ハマりました。
「あるクラスのshared(T)をTにしたい。他のスレッドは触らないから実質スレッドローカルなのだけど」という状況だったので、単純にキャストで対応しています。

対応は、型にimmutableをつけるassumeUniqueにならってassumeThreadLocalという関数を作り、以下のような実装になっています。

2.077.0で動くように対応した例
auto ref T assumeThreadLocal(T)(auto ref shared(T) obj)
{
    return cast() obj; // cast()でsharedを外せる。忘れがちな書き方…
}

class C { } // こっちはクラスなのでatomicLoadからsharedで返ってくる
void main()
{
    shared C sc;
    C c = assumeThreadLocal(atomicLoad(sc)); // 他のスレッドで使われないことを確認して、使う側の責任で書く
}

一波乱

さて、上記の対応でできたできた、と思ったらそうでもありませんでした。

自分がこの対応をしたのは、自作のライブラリである rx というパッケージの中です。

これはLLVMをバックエンドに持つコンパイラのldcもサポートしているのですが、ldcのほうはまだ更新されていないので、atomicLoadがsharedを外す動きをします。

assumeThreadLocalは引数にshared変数を受け付けるので、非shared変数を戻されると今度はldcでエラーになります。

そんな状況下、一般公開しているライブラリとしては、dmdでもldcでもコンパイルできるようにする必要がありました。

あと一歩

さてどうするかというと、assumeThreadLocalのオーバーロードを追加して、非shared変数を受け流すようにしておきます。
どうせLDCが更新されれば一本化できるので、一時的な処置です。

// dmd 2.076.x以前およびldcで使われる方
auto ref T assumeThreadLocal(T)(auto ref T obj) if (!is(T == shared))
{
    return obj;
}

// dmd 2.077.0で使われる方
auto ref T assumeThreadLocal(T)(auto ref shared(T) obj)
{
    return cast() obj;
}

class C  { }
void main()
{
    shared C sc;
    C local = assumeThreadLocal(sc); // コンパイラやバージョンによらず有効
}

これで無事に動くようになりました。めでたしめでたし。

まとめ

sharedを使いこなして最適なプログラムを書くのは難しいですが、なんだかんだとatomicLoadを使っていて、破壊的変更が避けられない状況になりました。

マルチスレッド周りでsharedをかじったことがある方は、ぜひ一度手元のコードをビルドして破壊的変更を楽しんでください!

以上!