公開技術情報

[English] [Japanese]

81. 安全な非同期処理

LuneScript から go 言語へのトランスコンパイル対応したのを機に、 LuneScript による非同期処理を対応しました。

静的にデータ競合を排除する簡易機能を持ちます。

__Runner インタフェース

LuneScript で非同期処理を行なうには、 __Runner インタフェースを実装します。

__Runner インタフェースは、以下の型です。 __async については後述します。

// @lnsFront: skip
pub interface __Runner {
   pub fn run() __async mut;
}

このインタフェースを実装すると、 新しい組込み関数の __run(), __join() を利用できます。

例えば、以下のような処理を実行すると、 print("hoge:", self.val ); が非同期で処理されます。

// @lnsFront: ok
class Hoge extend (__Runner) {
   let val:int;
   pub fn run() __async mut {
      print("hoge:", self.val );
   }
}

let list:List<Hoge> = [];
for index = 0, 10 {
   let mut hoge = new Hoge(index);
   __run( hoge, __lns.runMode.Sync, "" );
   list.insert( hoge );
}
foreach hoge in list {
   __join( hoge );
}

__run() 関数

__run() 関数は、 __Runner クラスの非同期実行を開始するための関数です。

非同期実行が開始されると、 __Runner クラスの run() メソッドが別スレッドで実行されます。

__run() 関数の型は以下の通りです。

// @lnsFront: skip
pub fn __run( runner:__Runner, mode: RunMode, name:str ) : bool
  • 第 1 引数の runner には、 実行する __Runner オブジェクトを指定します。
  • 第 2 引数の mode には、 以下を指定します。

    • __lns.runMode.Sync

      • 実行中の __Runner 数が一定数越えた場合、 新しくスレッドは起動せずにここで実行する。
    • __lns.runMode.Queue

      • 実行中の __Runner 数が一定数越えた場合、 Runner queue に入れ、 実行中の __Runner が停止した時に実行する。
    • __lns.runMode.Skip

      • 実行中の __Runner 数が一定数越えた場合、 Runner を実行しない。
      • 実行しなかった場合、 false を返す。
  • 第 3 引数の name には、この非同期処理の名前を指定する。

lua にトランスコンパイルした場合、以下の動作になります。

  • mode が __lns.runMode.Sync あるいは __lns.runMode.Queue の場合、 新しくスレッドは起動せずに、ここで実行する。
  • mode が __lns.runMode.Skip の場合、実行せずに false を返す。

__join() 関数

__join() 関数は、 __Runner の非同期処理の終了を待つ関数です。

// @lnsFront: skip
pub fn __join( runner:__Runner )

lua にトランスコンパイルした場合、非同期処理はないため何もしません。

コンストラクタの引数の制限

__Runner を extend するクラスのコンストラクタの引数は、 以下の型に制限されます。

  • int, real, str, bool, enum
  • immutable な型
  • 次の条件を満すクラスのオブジェクト ( v1.6.0 から )

    • final で、且つ、公開メンバを持たず、公開メソッドが全て __noasyc

つまり、以下のケースはエラーになります。

// @lnsFront: error
class Test {
   pub fn func() __async {
   }
}
final class Foo {
   pub fn func() __noasync {
   }
}
class Hoge extend (__Runner) {
   pub fn __init( test:Test, list:List<int>, foo:Foo ) __async { // error
   }
   pub fn run() __async mut {
   }
}

このエラーは、 引数の test と list が mutable な型であるためです。

以下のように immutable としての宣言が必要です。

なお、 foo は全てのメソッドが __noasync であるため、 mutable のまま渡すことができます。

// @lnsFront: ok
class Test {
   pub fn func() __async {
   }
}
final class Foo {
   pub fn func() __noasync {
   }
}
class Hoge extend (__Runner) {
   pub fn __init( test:&Test, list:&List<int>, foo:Foo ) __async { // ok
   }
   pub fn run() __async mut {
   }
}

__async, __noasyc 属性

__Runner インタフェースの run() メソッドの定義を見ると、 __async が追加されているのが分かります。

これは、その関数を非同期に実行可能であることを宣言しています。

LuneScript は、スレッドが一つだけで動作する従来の同期処理と、 新しくスレッドを起動して実行する非同期処理に分けて管理します。

ある関数を非同期で実行するには、 その関数が非同期で実行可能であることを宣言する必要があります。

それが __async です。

一方で、従来の同期処理は __noasyc です。

普通は async の対になるのは sync だと思いますが、 以下の理由からあえて noasync にしています。

  • async と sync だと区別しづらい
  • 主体が非同期処理(async)であり、 同期処理は例外だから noasync

__async, __noasync どちらも宣言していない場合はデフォルトで __noasyc ですが、 デフォルトを __async として扱える方法を用意しています。

__async 宣言された関数の制限

__async 宣言された関数には、以下の制限があります。

  • __async 宣言された関数内から __noasync 宣言された関数にアクセスできない。
  • __async 宣言された関数内から、スコープ外の mutable な変数にアクセスできない。

一方で __noasync 宣言された関数には、このような制限はありません。

これは、安全に非同期処理を実行するためのガードです。

非同期処理は、 排他制御 を考慮する必要があります。 必要な箇所で 排他制御 を行なわないと、バグになります。

排他制御の必要性については、ここを参考に。

しかし、どこに 排他制御 が必要か、 を全てのケースにおいて人手で網羅することは非常に困難です。

そこで、 LuneScript では文法上にメタ情報を宣言し、 その不整合をコンパイラがチェックすることによって、 ヒューマンエラーによる 排他制御 の抜け漏れを軽減する方法を採用しています。

このアプローチを採用する代表的な言語に Rust があります。

Rust は厳格なメタ情報の定義によって、高度な 排他制御 を実現しています。

LuneScript では、 Rust ほど高度な 排他制御 を実現していない変わりに、 比較的に手軽で扱い易いメタ情報定義を採用しています。

なお、後述する __asyncLock を利用することで、 __async から __noasync をアクセスすることが可能 になります。

__async 宣言された関数内から __noasync 宣言された関数は実行できない。

これは、以下のケースがエラーになることを指します。

// @lnsFront: error
class Test {
   fn func1() __noasync {
   }
   fn func2() __async {
      self.func1(); // error
   }
}

上記 func1 は __noasync で、 func2 は __async です。 このとき、 __async の func2 から __noasync の func1 はアクセスできません。

__async 宣言された関数内から、スコープ外の mutable な変数にアクセスできない。

これは、以下のケースがエラーになることを指します。

// @lnsFront: error
let mut list = [ 1, 2 ];
let list2 = [ 1, 2 ];
class Test {
   fn func() __async {
      foreach val in list { // error
         print( val );
      }
      foreach val in list2 { // ok
         print( val );
      }
   }
}

上記 func は __async で、 list は最上位スコープの mutable な変数です。

このとき、 __async の func から mutable の list にはアクセスできません。

一方で、 list2 は immutable です。 immutable な変数には func からアクセス可能です。

また __noasync のメソッドからは、 mutable なメンバにアクセス可能です。

__async:__noasyc == N:1

LuneScript では、 非同期(__async)で動作するスレッドが複数(N)あり、 __noasync で動作するスレッドは 1 つになるように設計しています。

__noasync で動作するスレッドが複数あると、もはやそれは非同期なので、 __noasync のスレッドが一つなのは当然ですね。

__async の制限を一時的に解除する方法。 (__asyncLock)

前述の通り、 __async 宣言された関数には制限があります。

理想は、全てにおいてこの制限を満すことですが、 現実問題それでは対応できないケースもあります。

そこで、 __async 宣言された関数の制限を一時的に解除する方法を用意しています。

それが __asyncLock です。

例えば、以下のように __asyncLock を利用します。

// @lnsFront: ok
class Test {
   fn func1() __noasync {
   }
   fn func2() __async {
      __asyncLock {
         self.func1(); // ok
      }
   }
}

func2 は __async なので、__noasync である func2 に本来はアクセスできませんが、 __asyncLock ブロック内では __async の制限が解除されます。

__asyncLock と __noasync の関係

__asyncLock は、 __async 宣言された関数を一時的に __noasync として動作させます。

そして前述している通り、 __noasync として動作するスレッドは 1 つでなければなりません。

そこで__asyncLock は、 __noasync スレッドが実行中は、実行停止まで待ち、 __noasync スレッドの実行停止後に _asyncLock のブロックを実行します。

他の __asyncLock のブロック実行中も __noasync スレッド実行中と同様に扱います。

関数を跨いだ __asyncLock のネスト

次のケースでは、 func3 -> func2 -> func1 とコールしています。

この時に、 func3, func2 で __asyncLock していますが、 func2 実行時には既の __noasync として実行しているため、 func2 の __asyncLock はブロックせずに実行されます。

// @lnsFront: ok
class Test {
   fn func1() __noasync {
   }
   fn func2() __async {
      __asyncLock {
         self.func1();
      }
   }
   fn func3() __async {
      __asyncLock {
         self.func2();
      }
   }
}

このように、関数を跨いだ __asyncLock はネストできます。

一方で同一関数内の __asyncLock はネストできません。エラーします。

// @lnsFront: error
class Test {
   fn func1() __noasync {
   }
   fn func2() __async {
      __asyncLock {
         __asyncLock { // error
            self.func1();
         }
      }
   }
}

__asyncLock のオーバーヘッド

前述の通り、 __asyncLock は排他制御を行ないます。

排他制御はオーバーヘッドがかかるので、__asyncLock の利用は最小限にすべきです。

例えば次のように for ループ内で __asyncLock を使うと、 ループ分のオーバーヘッドが余計に加算されます。

// @lnsFront: ok
class Test {
   fn func1() __noasync {
   }
   fn func2() __async {
      for _ = 1, 10000000 {
         __asyncLock {
            self.func1();
         }
      }
   }
}

この場合は、 __asyncLock を for ループの外に出すのが良いです。 しかし、for ループの外に出すと、 排他される範囲が広くなりすぎるケースもあります。

どの範囲を __asyncLock するかは慎重に判断する必要があります。

__asyncLock の制限

__asyncLock には以下の制限があります。

  • __asyncLock 内から return, break できない。 ※version 1.6.1 からは、__asyncLock 内から return, break できるようになっています。

つまり、以下のような処理は出来ません。

// @lnsFront: error
class Test {
   fn func1() __noasync : bool {
      return true;
   }
   fn func2() __async : int {
      __asyncLock {
         if self.func1() {
            return 1; // error
         }
      }
      return 0;
   }
}

このような処理を行なう場合は、以下のよう書きます。

// @lnsFront: ok
class Test {
   fn func1() __noasync : bool {
      return true;
   }
   fn func2() __async : int {
      let mut val = 0;
      __asyncLock {
         if self.func1() {
            val = 1;
         }
      }
      return val;
   }
}

デフォルトを __async にする

何も宣言していない関数は __noasync です。

これを、 デフォルト __async にする方法を用意しています。

_lune_control default_async_all

上記が .lns ファイルの先頭に宣言されている場合、 その .lns ファイル内では デフォルト __async になります。

ソフトウェアデザイン

これまでの特徴をまとめると以下になります。

  • __async スレッドが複数(N)で __noasync スレッドは 1 つの N:1 になる。
  • 他に __noasync スレッド実行中、 __asyncLock はブロックする。

これらから、 LuneScript において非同期処理を行なうには以下が必要になります。

「基本は __Runner で __async 処理し、 __noasync の処理は必要最低限に留める」

例えば、 __main() で起動直後に __Runner を起動し、その __Runner の終了を __join で待つ。 というデザインをするのが基本となります。

安全に非同期制御を行なうためのメカニズム

LuneScript では、以下によって排他制御の抜け漏れを防止しています。

  • __async 宣言された関数の制限
  • __Runner を extend したクラスのコンストラクタの制限
// @lnsFront: error
let mut list = [ 1, 2 ];
class Test {
   fn func() __async {
      foreach val in list { //error
         print( val );
      }
   }
}

例えば、上記の func() から list へのアクセスは本来コンパイルエラーになりますが、 これをエラーとしない場合、 func() メソッドを実行している間に、 別のスレッドで list の値を更新した場合、 list に対して参照と変更が同時に発生し、不定な動作になります。

これをガードするために、 __async 宣言した関数には制限が付きます。

また、次のようなコードを実行した場合、

// @lnsFront: error
class Hoge extend (__Runner) {
   let list:List<int>;
   pub fn __init( list:List<int> ) __async { // error
      self.list = list;

      __run( self, __lns.runMode.Queue, "test" );
   }
   pub fn run() __async mut {
      self.list.insert(1);
   }
}

let mut workList = [1];
let hoge1 = new Hoge( workList );
let hoge2 = new Hoge( workList );

本来 Hoge のコンストラクタの list の型が mutable であるためコンパイルエラーになりますが、 これをエラーとしない場合、同じ workList に対して、 複数の Hoge の非同期処理によって insert() が同時に発生し、 不定な動作になります。

これをガードするために、 __Runner を extend したコンストラクタには制限が付きます。

不完全な制限

前述している通り LuneScript の排他制御は、不完全です。

既に気付いている方もいると思いますが、 現状の制限を守っていても不定な動作を起すことが簡単に出来ます。

例えば、以下のコードの func() からアクセスする list2 は &List<int> なので immutable であり、 __async 関数の制限を満します。

// @lnsFront: ok
let mut list = [ 1, 2 ];
let list2 = list;
class Test {
   fn func() __async {
      foreach val in list2 {
         print( val );
      }
   }
}

しかし、func を非同期で実行中に別のスレッドから list を更新されると、 funcでアクセスしてる list2 は list と同じインスタンスであるため、 不定な動作になります。

コンストラクタの制限についても同じようなことを起せます。

このように、不完全な制限になってしまっていますが、 これは、プログラミング開発の手軽さと、 静的チェックの厳格さとのトレードオフな部分であり、 LuneScript ではバランスを取って現状はこの仕様になっています。

プログラミング開発の手軽さを保ちつつ、 静的チェックの厳格さを向上できる方法は今後も模索していきます。