💨

AndroidオーディオスレッドのディスパッチをALooperで実装する

2023/02/22に公開

さいきん自分のAudio Plugins For AndroidでGUIを制御する仕組みを作っていたので、オーディオアプリあるある、オーディオのrealtimeスレッドから何かしらのイベントをnon-realtimeスレッドに丸投げするメッセージディスパッチのやり方について書き残しておこうと思う。

ALooperとは

Android SDK APIにはandroid.os.Looperというクラスがある。これを使うと、現在のjava.lang.Threadに紐付けられたイベントループ機構の原型ができあがる。android.os.Handlerというメッセージオブジェクトを使って、このLooperjava.lang.Runnablepost()すると、そのRunnableLooperのスレッド上で実行されることになる。GUIのイベントループなどは、このLooperを使った"main looper"上で回っていることになる。

このLooperの仕組みは、Android NDKでもALooperというAPIによって利用できる。Android SDK APIのLooperもこのNDK APIのALooperも、同じネイティブコードの基盤の上に成り立っている。

ただし、ALooperのAPIはAndroid SDKのLooper APIとは使い勝手がだいぶ異なる。実のところ、android.os.Looperを使う場合は、全てAndroid SDK API / KotlinでLooperを操作し、ALooperを使う場合は全てAndroid NDK / C++でLooperを操作するのが適切だ。ALooperの関数ALooper_prepare()で初期化したLooperに対してLooper.loop()を使ってループを開始しようとすると No Looper; Looper.prepare() wasn't called on this thread. と言われてRuntimeExceptionが発生する。

Looper (Android SDK API) の場合

Android SDK APIのLooperの使い方は、基本形としてはこうだ:

  • Looper.prepare() で現在のスレッドに紐付けられるandroid.os.Looperを初期化する(イベントループはLooper.loop()を呼び出すまでは起動していない)
  • イベント(シグナル)を待機するHandlerを生成する。その際に、このLooperをコンストラクタ引数に渡す(これでそのHandlerはそのLooperのコンテキストで実行されることになる)
  • Looper.loop()で現在のスレッドのLooperのループ処理を開始する
  • 生成してあったHandlerRunnableインターフェースの実装をpost()メソッドで渡して非同期実行させる

Looperのループを実行するスレッド:

Looper.prepare()
val looper = Looper.myLooper()
val handler = Handler(looper)
looper.loop()

メッセージ送信側:

handler.post { ... } // { ... } の内容はLooperスレッドで実行される

ちなみにpost()に渡すRunnableがGUI処理であればMainスレッドで処理する必要があるし、それなら自分でLooperを用意しなくてもLooper.mainLooperに渡せば足りる。

ALooper (Android NDK API) の場合

これと同じことをALooperのAPIで書くとこうなる:

  • メッセージループを実行したいスレッド(pthread)上でALooper_prepare() を呼び出し、現在のスレッドに紐付けられるALooperを初期化する。イベントループは始まっているが、シグナルが何も登録されていないので何も起こらない。
  • イベント(シグナル)を待機するFile DescriptorをALooper_addFd()で登録する。一般的には、ここでpipe()が生成したFDのread側を渡す。この関数はイベント受信時に実行するコールバックを指定しておくと、FDにwrite()シグナルが届いたときに呼び出されるようになっている
  • メッセージループはALooper_pollOnce()あるいはALooper_pollAll()を呼び出すかたちで自分で実装する
  • メッセージを送るスレッドからこのFDにwrite()でメッセージを渡す(内容は任意。read()で読み取れるように合意しておく)
  • pipe()で紐付けられたread側のFDがLooperのコンテキストでシグナルを受信し、ALooper_addFD()で渡されたコールバック関数があればそれが呼び出される。

Looperのループを実行するスレッド:

int pipeFDs[2];
auto looper = ALooper_prepare(0);  
ALooper_acquire(looper);
assert(pipe(pipe_fds));
assert(ALooper_addFd(looper, pipe_fds[0], ALOOPER_POLL_CALLBACK, ALOOPER_EVENT_INPUT, handleMessageFunction, nullptr));
while(true) {  // simple message loop implementation
  int fd, events;  
  void* data;  
  ALooper_pollOnce(-1, &fd, &events, &data);  
}

メッセージ送信側:

void postMessage(SomeType someValue) {
  write(pipeFDs[1], someValue, sizeof(someValue));
}

メッセージ受信側(Looperスレッドで実行される)

static int handleMessageFunction(int fd, int events, void* data) {
  SomeType someValue;
  read(fd, &someValue, sizeof(someValue));
  // continue with actual message processing based on events, data, or someValue.
}

ALooperの操作を限りなくrealtime safeに近づける

ALooperはpthread上のコンテキストで生成されるので、このpthreadがjava.lang.Threadから生成されていたり、JNIのAttachCurrentThread()で関連付けられたりしていない限り、他のDalvikのスレッドから停止させたりすることはない。ただし、RT safeなALooperを作るのは、不可能ではないかもしれないが困難ではないかと思われる。pthreadそれ自身がRTスレッドでもない限り、そのALooper自体がRT safeに動作することはないし、RT thread上でRT safeなメッセージループを実現したいなら、ALooperは(特にALooper_addFd()は)pipeが前提になっているので、読み書き双方がnon-blocking I/Oで動作するようにコードを書く必要がある。読み取り側は特に自前でRT safeなロック機構が必要になるので、何かしら自前で実装したほうが早そうな気がする。

ALooperのスレッド自体はnon-RTでもよくて、そのスレッドに対してRTスレッドからメッセージを送りたい、という場合はどうだろうか。ALooperを使った場合、メッセージのシグナルにはPOSIXのwrite()を使うことになるのだけど、これはシステムコールなのでRT safeではないといわれている。ただ、pipeに対するwriteについては、書き込みがトータルでPIPE_BUF未満に収まっているうちはatomicであるとされている。そういうわけで、RTスレッドからwrite()で書き込むデータサイズを小さく抑えて、読み取り側がより小さい頻度でイベント処理を回していてもそんなに大量に書き込みが発生しないようになっていれば、POSIX write()が使われていたとしても現実的には問題なく処理できる可能性が十分にあるといえそうだ。

ALooperはAndroidのmedia API実装の一部であるstagefrightでも使われており、おそらく同様の前提に基づいてリアルタイム処理で使われていると考えられる。

(Android SDK APIのLooperはJVMのJITやらGCやらがRT safeではないので、「メッセージとして送りつけるRunnableを生成するだけ」でもRTスレッドからは行えない。ALooperを使うのが適切だ。)

ALooperを使うと、java.lang.Threadに結び付けられていないただのpthreadもLooperとして使うことができるが、JavaVMから後付でJNIEnvをアタッチされたAndroidのネイティブスレッドでは、ロードできるクラスがごく限られたsystemClassLoaderが使われることになり、Android環境で一般に使われるContext.classLoaderが解決できるクラスが解決できないことが多い。このような問題を回避するには、java.lang.Threadを生成して、そのRunnableの中でJNI経由でALooperを初期化するとよい。このjava.lang.ThreadLooperを使うと話がややこしくなるので、全てALooperのAPIで仕事を済ませるのが適切だ。

追記: AndroidにはPOSIX message queue (mqueue.h) の実装が含まれていない(linux/mqueue.hだけは存在していて、mq_attrだけは解決可能なシンボルになっている)。

Discussion