LoginSignup
13
8

More than 5 years have passed since last update.

Javaで無限ループで暴走したスレッドを外部から強制停止できないか実験したメモ

Posted at
  • Javaプログラミングの勉強中に、ロジックのバグでCPU使用率100%でスレッドが暴走したことがある。
  • ある特殊なデータを処理する場合のロジックバグであり、暴走しているのはそのデータを受け付けたスレッド1つのみ。それ以外のスレッドは正常に動作を続けている。
  • そのため、暴走したスレッドだけを外部から強制停止できないか、本メモにて調査・実験してみた。
    • 本メモにおいては、CPU使用率100%の部分は再現させず、単に Thread.sleep()InterruptedException を無視するループで実験した。

結論

2018-03-21時点の結論:

  • jdbで後からattach可能にしていない限りは、無限ループなどで暴走した特定のJavaスレッドだけを外部から終了させる方法は、無いと考えたほうが良さそう。
    • jdbでattachしてExceptionを投げて終了させるのと同等の処理をgdbでattach後呼び出せば可能だが、非常に実装依存となり、現実的ではないだろう。
    • 正常な他のスレッドも含め、Javaプロセスごと強制終了するしか無い、と諦めたほうが良さそう。
  • そもそもスレッドが無限ループするようなバグを作り込まず、Javaが推奨する方式できちんと終了するようなコードを書く努力をしたほうが良い。
  • 万一無限ループしても、そのJavaプロセスを強制終了→再起動してサービス提供が継続できるような冗長性・耐障害性を備えた設計やアーキテクチャにする。
    • 無限ループが発生しそうな危険な処理を別のプロセスとしてマイクロサービス化し、冗長性をもたせて提供するなど。

実験環境

CentOS6/7 の二種類で実験(いずれもx86_64版)。GCPのCompute Engineで起動し、以下のパッケージをインストール。

sudo yum install -y java-1.8.0-openjdk java-1.8.0-openjdk-devel
sudo yum groupinstall -y "Development tools"

Javaのバージョン詳細(2018-03-21確認時はCentOS6/7環境とも同じ表示になった)

$ java -version
openjdk version "1.8.0_161"
OpenJDK Runtime Environment (build 1.8.0_161-b14)
OpenJDK 64-Bit Server VM (build 25.161-b14, mixed mode)

$ javac -version
javac 1.8.0_161

無限ループのサンプル : InfiniteLoop.java

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class InfiniteLoop {
    static class Looper implements Runnable {
        @Override
        public void run() {
            int cnt = 0;
            while (true) {
                // 割り込みを無視した無限ループ
                try {
                    System.out.println("InifiniteLoop-Looper count " + cnt);
                    Thread.sleep(2000);
                } catch (InterruptedException ignored) {
                }
                cnt++;
            }
        }
    }

    public static void main(String[] args) {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        Future<?> f = pool.submit(new Looper());
        try {
            // Future.get()を呼び出してタスクの終了を待つ
            f.get();
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }
}

コンパイル:

$ javac InfiniteLoop.java

実験1 : jdbでattachしてスレッドを終了させる

※CentOS6/7両方で同様の結果となった。

参考:

後からjdbでattachするようのJVMオプションを指定して、InfiniteLoop クラスを実行する。

$ java -Xrunjdwp:transport=dt_socket,address=9000,server=y,suspend=n InfiniteLoop

別のターミナルからjavaのPIDを取得し、jstackでJavaのスタックトレースを確認する。

$ pidof java
13667

$ jstack 13667
2018-03-21 07:52:59
Full thread dump OpenJDK 64-Bit Server VM (25.161-b14 mixed mode):
(...)

"pool-1-thread-1" #10 prio=5 os_prio=0 tid=0x00007f0d840f3b30 nid=0x356f waiting on condition [0x00007f0d6d8c8000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(Native Method)
        at InfiniteLoop$Looper.run(InfiniteLoop.java:14)
        at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
        at java.util.concurrent.FutureTask.run(FutureTask.java:266)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
        at java.lang.Thread.run(Thread.java:748)

(...)

スタックトレースから pool-1-thread-1 という名前のスレッドが、無限ループ中のスレッドであることを確認できた。

スレッド名が分かったので、jdbを起動してJVMにattachし、ループ中のスレッドを終了する。

$ jdb -attach 9000
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
Initializing jdb ...

(スレッド一覧を表示)
> threads
Group system:
  (java.lang.ref.Reference$ReferenceHandler)0x1b9 Reference Handler cond. waiting
  (java.lang.ref.Finalizer$FinalizerThread)0x1ba  Finalizer         cond. waiting
  (java.lang.Thread)0x1bb                         Signal Dispatcher running
Group main:
  (java.lang.Thread)0x1                           main              cond. waiting
  (java.lang.Thread)0x1bd                         pool-1-thread-1   sleeping

(スレッド名 "pool-1-thread-1", 0x1bd がループ中のスレッドであることを確認。)

(止めたいスレッド : 0x1bd をsuspendし、stepした後にExceptionをthrowしつつkillする)
> thread 0x1bd
pool-1-thread-1[1] suspend 0x1bd
pool-1-thread-1[1] step
>
Step completed: "thread=pool-1-thread-1", InfiniteLoop$Looper.run(), line=16 bci=33
16                    }

pool-1-thread-1[1] kill 0x1bd new java.lang.Exception("kill from jdb")
killing thread: pool-1-thread-1
pool-1-thread-1[1] instance of java.lang.Thread(name='pool-1-thread-1', id=445) killed

(cont で再開したあと、デバッガを終了する)
pool-1-thread-1[1] cont
> exit

jdbの操作により、InfiniteLoopの実行結果は以下のように影響を受けた。

$ java -Xrunjdwp:transport=dt_socket,address=9000,server=y,suspend=n InfiniteLoop
(...)
InifiniteLoop-Looper count 32
InifiniteLoop-Looper count 33
InifiniteLoop-Looper count 34
(jdbからsuspendを実行 -> count表示が止まる)
(jdbからkillすると以下が表示される)
java.util.concurrent.ExecutionException: java.lang.Exception: kill from jdb
        at java.util.concurrent.FutureTask.report(FutureTask.java:122)
        at java.util.concurrent.FutureTask.get(FutureTask.java:192)
        at InfiniteLoop.main(InfiniteLoop.java:27)
Caused by: java.lang.Exception: kill from jdb
        at InfiniteLoop$Looper.run(InfiniteLoop.java:16)
        at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
        at java.util.concurrent.FutureTask.run(FutureTask.java:266)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
        at java.lang.Thread.run(Thread.java:748)
(jdbを終了すると以下が表示される)
Listening for transport dt_socket at address: 9000
(Ctrl-Cで終了)

jdbからのkillによりRunnableタスクが終了すると共に、Future.get() 待ちがExecutionExceptionがスローされて解放されたことが分かる。
またExcecutionExceptionの原因が、jdbのkillでスローした例外が原因となっていることも確認できる。

実験2 : gdbでattachし、pthread_kill(2)によりスレッドを終了させる

  • JavaのスレッドはLinuxにおいてはpthreadで実現されている(らしい)。
  • そうなると、pthreadと同様、あるプロセスのpthreadを外部のプロセスからシグナルなどで終了させることはできない。
  • ただし、gdbでプロセスにattachし、pthread_kill(2)を呼ぶことで「内部から」シグナルを送って終了させることはできる。
  • Javaのスレッドでそのアプローチが可能か、簡単な例(SIGKILL = 9)で実験してみた。

参考:

特にデバッグ用オプションをつけず InfiniteLoop クラスを実行する。

$ java InfiniteLoop

java実行ファイルの位置を確認:

$ which java
/usr/bin/java
$ ls -l /usr/bin/java
(...) /usr/bin/java -> /etc/alternatives/java
$ ls -l /etc/alternatives/java
(...) /etc/alternatives/java -> /usr/lib/jvm/jre-1.8.0-openjdk.x86_64/bin/java
$ ls -l /usr/lib/jvm/jre-1.8.0-openjdk.x86_64/bin/java
(...) /usr/lib/jvm/jre-1.8.0-openjdk.x86_64/bin/java

JavaのPIDを確認し、スタックトレースを取得する。

$ jstack `pidof java`
2018-03-21 08:28:38
Full thread dump OpenJDK 64-Bit Server VM (25.161-b14 mixed mode):
(...)

"pool-1-thread-1" #8 prio=5 os_prio=0 tid=0x00007ffb500ed000 nid=0x6a05 waiting on condition [0x00007ffb54146000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(Native Method)
        at InfiniteLoop$Looper.run(InfiniteLoop.java:14)
        at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
        at java.util.concurrent.FutureTask.run(FutureTask.java:266)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
        at java.lang.Thread.run(Thread.java:748)
(...)

Linuxにおいては、jstackで表示されるスレッド情報の nid=0x... が pthread_kill(2) で指定するLWP IDになる。

gdb 実行ファイル名 PID でattachし、info threads コマンドを実行してみる。

$ gdb /usr/lib/jvm/jre-1.8.0-openjdk.x86_64/bin/java `pidof java`

(gdb) info threads
(...)
  4 Thread 0x7ffb54248700 (LWP 27140)  0x0000003e2020ba5e in pthread_cond_timedwait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0
  3 Thread 0x7ffb54147700 (LWP 27141)  0x0000003e2020ba5e in pthread_cond_timedwait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0
  2 Thread 0x7ffb40fff700 (LWP 27164)  0x0000003e2020eb2d in accept () from /lib64/libpthread.so.0
* 1 Thread 0x7ffb57342700 (LWP 27131)  0x0000003e202082fd in pthread_join () from /lib64/libpthread.so.0

gdbで info threads コマンドを実行した結果では10進数表示にとなる。
今回表示された無限ループのスレッドの nid=0x6a05 を10進数に直した 27141 を探すと、下から3番目のスレッドがそれに該当する。

  3 Thread 0x7ffb54147700 (LWP 27141)  0x0000003e2020ba5e in pthread_cond_timedwait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0

ではここで call pthread_kill(pthread_t, int) を呼ぶが・・・ 最初の引数には何を指定すればよい? info threads 実行結果の該当行の、どれを指定すればよいのか?
また、第二引数のシグナルには何を指定すれば、Javaの Thread.stop() 相当になるのか?

そもそも、Javaでスレッドが終了する時と全く同じ動作を gdb で attach したプロセス内から呼び出すにはどうすれば良い?

→これで完全に手詰まりになった。試しに適当に呼んでみても、JavaプロセスのほうがSIGSEGVで終了するなど、とてもじゃないが「暴走したスレッドのみを隔離して終了させる」ような結果にはならなかった。

仮にJavaのソースコードを読み解いたり strace でシステムコールを解析するなどして、jdb で終了させたときと同様に内部から例外を発生させる処理をgdbで再現可能だとしても、それは恐らく相当内部実装に依存したものとなり、実際の運用環境で安定かつ容易に再現できるものとはならないのではないか?

2018-03-21時点のまとめ

  • 無限ループなどで暴走した特定のJavaスレッドのみを終了させる方法は、jdbでattachして内部から例外をスローさせる方法が一番確実であると思われる。
  • gdb でattachして内部から call pthread_kill()call raise() などでシグナルを送って終了させることも原理的には可能と思われるが、今回の実験では正しい手順を調べきれず、検証できなかった。
    • 恐らく仮にできたとしても、本来のJVM内でのスレッド終了とは異なる状況となるため、JVM自体が異常終了する可能性が高いと思われる。
    • また、内部から例外をスローさせるのと同等な処理を行うにしても、シンボルテーブルやメモリレイアウトに影響を受けると思われるため、日々の運用環境で容易に再利用できる手順になるとは思えない。
  • 以上より、最初からjdbでattach可能となっているJavaプロセスで無い限りは、暴走した特定のJavaスレッドのみを終了させることは難しく、諦めたほうが良さそうであると結論付ける。
  • それよりもテストをきちんと行うなどして、そもそも暴走するようなバグを埋め込まないよう努力するほうが良いだろう。
  • Javaのドキュメントにあるように、きちんとスレッドが終了するよう開発する努力も重要だろう。
  • あるいは、冗長構成や耐障害性を確保した設計とアーキテクチャを実現することで、仮に暴走してもそのスレッドを含んだJavaプロセスを強制停止→再起動してもサービスを継続できるようにするのも良いだろう。

綺麗にまとまらなかったけど以上です。

13
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
8