AndroidオーディオスレッドのディスパッチをALooperで実装する
さいきん自分のAudio Plugins For AndroidでGUIを制御する仕組みを作っていたので、オーディオアプリあるある、オーディオのrealtimeスレッドから何かしらのイベントをnon-realtimeスレッドに丸投げするメッセージディスパッチのやり方について書き残しておこうと思う。
ALooperとは
Android SDK APIにはandroid.os.Looper
というクラスがある。これを使うと、現在のjava.lang.Thread
に紐付けられたイベントループ機構の原型ができあがる。android.os.Handler
というメッセージオブジェクトを使って、このLooper
にjava.lang.Runnable
をpost()
すると、そのRunnable
がLooper
のスレッド上で実行されることになる。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のループ処理を開始する - 生成してあった
Handler
にRunnable
インターフェースの実装を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.Thread
でLooper
を使うと話がややこしくなるので、全てALooperのAPIで仕事を済ませるのが適切だ。
追記: AndroidにはPOSIX message queue (mqueue.h
) の実装が含まれていない(linux/mqueue.hだけは存在していて、mq_attr
だけは解決可能なシンボルになっている)。
Discussion