言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

kotlin.concurrent.threadは新規threadが作成されるけどcurrent threadのgroupで動く

[2018.7.15 ご指摘いただき記事の最後に追記しました]

あらまし

先日おもしろいTweetを見つけたのでちょっと調べてみたメモ。

残念ながら手元で上記の現象は再現しなくて、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 nameprioritygroup 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引数を置いておいて、メソッドの頭でJavajava.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)を入れると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();

mThreadThread.currentThread() もsleep前後で変化しないはずなのでおかしいなぁと思っていたら

というわけでcheckThread()が呼ばれない可能性を考える。

ViewRootImplmHandlingLayoutInLayoutRequestというフラグを持っていて、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()が呼ばれず例外が発生しなかっただけだと思われる。

ViewRootImplAndroid FrameworkのクラスなのでDebuggerが効かず本当にそうか?というのが確認できないので歯がゆいが、多分あってるんじゃ無いかなぁ。多分。

[2018.7.15 追記ここまで]