kotlin.concurrent.threadは新規threadが作成されるけどcurrent threadのgroupで動く
[2018.7.15 ご指摘いただき記事の最後に追記しました]
あらまし
先日おもしろいTweetを見つけたのでちょっと調べてみたメモ。
別ActivityでUI操作の必要なマルチスレッド処理をしたいとき、runOnUiThreadを使わないとMarshmallowならクラッシュするけど、Oreoだとthreadだけで動作するという謎知見を得た
— omega (@equal_001) 2018年7月13日
残念ながら手元で上記の現象は再現しなくて、MashmallowでもCrashが発生しなかったんだけど、
Kotlinのthreadってmain threadじゃない別threadで動くんじゃなかったっけ…?🤔
と思ったので調査。
ちなみにMashmallowの端末が手元になかったのでEmulator環境でやってます。
まずは調査用の簡単なコードを準備。Activityの onResume()
でUI操作を行う。(textという名前のTextViewに"hogehoge"というtextをセットしています。)
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_thread) Timber.plant(Timber.DebugTree()) // main threadのログ出力結果を確認するためにログを入れる Timber.d("main thread: ${Looper.getMainLooper().thread}") } override fun onResume() { super.onResume() thread { Timber.d("current thread: ${Thread.currentThread()}") text.text = "hogehoge" } }
ThreadのtoString()
で表示しているのは下記の三つ。 thread name
と priority
と group name
public String toString() { ThreadGroup group = getThreadGroup(); if (group != null) { return "Thread[" + getName() + "," + getPriority() + "," + group.getName() + "]"; } else { return "Thread[" + getName() + "," + getPriority() + "," + "" + "]"; } }
ログとるときの操作は、単純に画面を表示させるだけ。結果はこんな感じ。
07-14 08:31:01.772 5497-5497/? D/ThreadActivity: main thread: Thread[main,5,main] 07-14 08:31:01.777 5497-5513/? D/ThreadActivity$onResume: current thread: Thread[Thread-266,5,main]
thread blockの中でログを出力した方は確かにthread nameを見ると別のthreadになっている。しかし3つ目のgroup nameはmainになっている。
kotlin.concurrent.threadを読んでみよう
Kotlinのthreadは気軽にサンプルコードのように書いている人が多いと思うが、じつはたくさんのdefault引数が用意されている
public fun thread(start: Boolean = true, isDaemon: Boolean = false, contextClassLoader: ClassLoader? = null, name: String? = null, priority: Int = -1, block: () -> Unit): Thread { val thread = object : Thread() { public override fun run() { block() } } if (isDaemon) thread.isDaemon = true if (priority > 0) thread.priority = priority if (name != null) thread.name = name if (contextClassLoader != null) thread.contextClassLoader = contextClassLoader if (start) thread.start() return thread }
で、そんなdefault引数を置いておいて、メソッドの頭でJavaのjava.lang.Thread
をnewしているんだけど、このときcurrent threadをparentにとるようになっているので、main threadからthread()
を呼び出すとmain threadのgroupで新規threadが作られるのだ。
private void init(ThreadGroup g, Runnable target, String name, long stackSize) { Thread parent = currentThread(); if (g == null) { g = parent.getThreadGroup(); } g.addUnstarted(); this.group = g;
UI操作できるのは一つのthreadからだけ
ちなみに上記のサンプルコードで、一度backgroundに行って再びonResume()
に帰ってくるとアプリがcrashする。
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
backgroundから復帰した時にonResume()
でもう一度threadから新規threadを作成しているので最初にさわったthreadではない別のthreadからUIを触ろうとしてcrashする。
ログを見てもthread nameが変わっていることがわかる
// Activity起動 07-14 08:31:01.772 5497-5497/? D/ThreadActivity: main thread: Thread[main,5,main] 07-14 08:31:01.777 5497-5513/? D/ThreadActivity$onResume: current thread: Thread[Thread-266,5,main] // Backgroundから復帰(2回目のonResume) 07-14 08:31:06.983 5497-5518/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[Thread-269,5,main]
ここでthreadを新規作成するのではなくmain threadに固定することでこのcrashは避けられる。
override fun onResume() { super.onResume() // この部分を // thread { // Timber.d("current thread: ${Thread.currentThread()}") // text.text = "hogehoge" // } // こう変える runOnUiThread { Timber.d("current thread: ${Thread.currentThread()}") text.text = "hogehoge" } }
ログを見たら全てmain threadで固定されていることがわかる。
// Activity起動 07-14 08:07:18.328 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity: main thread: Thread[main,5,main] 07-14 08:07:18.329 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[main,5,main] // Backgroundから復帰(2回目のonResume) 07-14 08:07:27.811 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[main,5,main]
ちなみにもう一つおもしろい挙動を見つけたのでメモ。 こんな感じでthreadとrunOnUiThread両方書いてやるとCrashは発生しない。
override fun onResume() { super.onResume() thread { Timber.d("current thread from thread: ${Thread.currentThread()}") text.text = "hogehoge" } runOnUiThread { Timber.d("current thread from ui thread: ${Thread.currentThread()}") text.text = "hogehoge" } }
ログを見ると別threadよりも先に runOnUiThread
の中が実行されているのがわかる。先にmain threadでさわっているので以降main threadのgroupで動いている別threadでさわってもcrashしないということなのかな。 // この部分間違ってそうなので追記で捕捉しました(2018.7.15)
// 1回目のonResume() 07-14 08:40:11.749 5693-5693/? D/ThreadActivity$onResume: current thread from ui thread: Thread[main,5,main] 07-14 08:40:11.749 5693-5709/? D/ThreadActivity$onResume: current threa from thread: Thread[Thread-278,5,main] // 2回目のonResume() 07-14 08:40:49.488 5693-5693/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread from ui thread: Thread[main,5,main] 07-14 08:40:49.489 5693-5717/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current threa from thread: Thread[Thread-280,5,main]
[2018.7.15ここから追記]
ブログ公開ごにhydrakecatさんにご指摘いただいたのでさらに追加調査。
自分もちょっと調べただけなのですが、たぶんこのサンプルだとたまたまうまくいっているだけな気がします。thread { } の中で Thread.sleep(1000) とか呼んでみるとクラッシュすると思います。見当違いなことを言っていたらすみません m(_ _)m
— Hiroshi Kurokawa (@hydrakecat) July 14, 2018
確かに thread { } の中で Thread.sleep(1000)を入れるとcrashするようになった。 (emulator環境Mashmallow / 実機環境Oreo共にcrashすることを確認 )
override fun onResume() { super.onResume() thread { text.text = "hogehoge" // sleep以前にtextをさわってもcrashしないが、 Thread.sleep(1000) // 1000msecの遅延後 text.text = "hogehoge" // textをさわるとcrashする }
例外を投げているのはViewRootImpl.java
のこの部分。
Cross Reference: /frameworks/base/core/java/android/view/ViewRootImpl.java
7311 void checkThread() { 7312 if (mThread != Thread.currentThread()) { 7313 throw new CalledFromWrongThreadException( 7314 "Only the original thread that created a view hierarchy can touch its views."); 7315 } 7316 }
mThread
に何が入るのかとういうと、constructorでThread.currentThread()
を代入している。mThread
はfinalなのでsleep前後でも変わらない。
235 final Thread mThread; 478 public ViewRootImpl(Context context, Display display) { 479 mContext = context; 480 mWindowSession = WindowManagerGlobal.getWindowSession(); 481 mDisplay = display; 482 mBasePackageName = context.getBasePackageName(); 483 mThread = Thread.currentThread();
mThread
も Thread.currentThread()
もsleep前後で変化しないはずなのでおかしいなぁと思っていたら
たぶんですが、親Viewがレイアウト中だとうまく無視されるんじゃないかと思っています。そしてTextViewにテキストを反映するときにうまく変更後の文字列を拾ってくれるんじゃないかと推測しています。mainスレッドで触った直後に別スレッドで触ったらクラッシュしなかったのもたぶん同じ理由かとー。
— Hiroshi Kurokawa (@hydrakecat) July 15, 2018
というわけでcheckThread()
が呼ばれない可能性を考える。
ViewRootImpl
はmHandlingLayoutInLayoutRequest
というフラグを持っていて、Layout実行時にこのフラグがtrueになる。
1158 @Override 1159 public void requestLayout() { 1160 if (!mHandlingLayoutInLayoutRequest) { 1161 checkThread(); 1162 mLayoutRequested = true; 1163 scheduleTraversals(); 1164 } 1165 }
で、requestLayout()
にこのフラグが立っていたら checkThread()
が実行されない(というか何もしない)ので、threadのcheckも行われず例外も投げられないということのようだ。sleep後はlayoutが完了してフラグがfalseになっているのでthreadのcheckが行われて例外が発生している。
runOnUiThread()
と thread
両方書いたケースでも、runOnUiThread()
の方でlayout要求が出ているのでmHandlingLayoutInLayoutRequest
がtrueになり、そのタイミングでthread
でUIをさわったのでcheckThread()
が呼ばれず例外が発生しなかっただけだと思われる。
ViewRootImpl
はAndroid FrameworkのクラスなのでDebuggerが効かず本当にそうか?というのが確認できないので歯がゆいが、多分あってるんじゃ無いかなぁ。多分。
完全に理解した!と言いたいけど、ViewRootImplの中でbreak pointおいても止まらないし、ログ埋め込むわけにもいかないので多分あってるくらいなのが歯がゆい…Ubuntu環境があればログ追加したfreamwork.jar作って挙動確かめたい🙄組み込み系の人、誰か気軽にできるなら代わりにやってほしい笑
— むーむー/Atsuko FUKUI (@muumuumuumuu) July 15, 2018
[2018.7.15 追記ここまで]