記一個線程阻塞問題的分析過程 Long monitor contention kill -3 命令 schedstat 線程鎖定位

最近遇到了一個線程阻塞的問題,分析的過程比較有代表性,這裏做個總結分享下。

測試報的問題是: 概率性出現開機的前幾分鐘我們的服務不可用。

查看日誌發現開機之後的幾分鐘之內mqtt庫不斷在斷開、重連broker。MqttCallback.disconnected一直在被調用,而且還能看到發佈消息也失敗了:

12-28 18:27:18.948   812   884 E LinJwDemoMqtt: publish failed : 等待來自服務器的響應時超時 (32000)
12-28 18:27:18.948   812   884 E LinJwDemoMqtt:     at d.c.a.a.a.l(Unknown Source:9)
12-28 18:27:18.948   812   884 E LinJwDemoMqtt:     at g.b.a.a.a.l.b.a(:4)
12-28 18:27:18.948   812   884 E LinJwDemoMqtt:     at g.b.a.a.a.k$b.run(:8)
12-28 18:27:18.948   812   884 E LinJwDemoMqtt:     at java.util.TimerThread.mainLoop(Timer.java:562)
12-28 18:27:18.948   812   884 E LinJwDemoMqtt:     at java.util.TimerThread.run(Timer.java:512)

過了幾分鐘之後就恢復了,能夠和mqtt broker正常通訊。

正常情況下斷開連接應該是網絡問題,但是發生如果是網絡問題應該是一直連不上,而不會連接上又斷開連接。除非剛開機的時候系統網絡模塊異常抽風,從日誌上看網絡是正常的,而且在broker的日誌裏面看到的是client主動斷開的連接:

2022-12-28T18:27:19: Client LinJwDemoMqtt_1672223154916 closed its connection.

Long monitor contention

這樣看來問題還是出在客戶端,仔細翻看首次出現異常恢復正常的那段時間的日誌,在恢復正常的時候發現了這樣的打印:

Long monitor contention with owner MQTT Call: LinJwDemoMqtt_1672223154916 (1252) at void java.lang.Thread.sleep(java.lang.Object, long, int)(Thread.java:-2) waiters=0 in java.util.List d.d.e.c.k.b.f.f(java.lang.String, java.lang.String) for 270.749s

這行日誌的意思是tid爲1252的LinJwDemoMqtt_1672223154916線程長期持有了對象的monitor,導致d.d.e.c.k.b.f.f這個方法等待了270.749秒才獲取到線程鎖。

這裏的monitor指的就是synchronized關鍵字的底層實現。正常情況對一段代碼進行加鎖應該是一個短時間的行爲,一旦某個線程長時間持有對象鎖就容易導致其他線程卡死。monitor會去監控等待鎖的時長,如果超過某個閾值(正常是100ms,調試模式下是1s)就會輸出上面的Long monitor contention打印提醒我們:

// https://cs.android.com/android/platform/superproject/+/android-9.0.0_r60:art/runtime/monitor.cc
static constexpr uint64_t kDebugThresholdFudgeFactor = kIsDebugBuild ? 10 : 1;
static constexpr uint64_t kLongWaitMs = 100 * kDebugThresholdFudgeFactor;

...
} else if (wait_ms > kLongWaitMs && owners_method != nullptr) {
uint32_t pc;
ArtMethod* m = self->GetCurrentMethod(&pc);
// TODO: We should maybe check that original_owner is still a live thread.
LOG(WARNING) << "Long "
    << PrettyContentionInfo(original_owner_name,
                            original_owner_tid,
                            owners_method,
                            owners_dex_pc,
                            num_waiters)
    << " in " << ArtMethod::PrettyMethod(m) << " for "
    << PrettyDuration(MsToNs(wait_ms));
}
...

例如下面的代碼sleep1會卡住sleep2:

private void testLongMonitor() {
    new Thread("TestLongMonitor1") {
        @Override
        public void run() {
            super.run();
            sleep1();
        }
    }.start();

    new Thread("TestLongMonitor2") {
        @Override
        public void run() {
            super.run();
            sleep2();
        }
    }.start();
}

private synchronized void sleep1() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

private synchronized void sleep2() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

在進入sleep2的時候就能看到下面的打印:

Long monitor contention with owner TestLongMonitor1 (3457) at void me.linjw.demo.MainActivity.sleep1()(MainActivity.java:41) waiters=0 in void me.linjw.demo.MainActivity.sleep2() for 1s

所以當我們看到這個打印的時候就應該去檢查下是否在上鎖的代碼塊裏面做了耗時操作。

kill -3 命令

再來看看這個日誌:

Long monitor contention with owner MQTT Call: LinJwDemoMqtt_1672223154916 (1252) at void java.lang.Thread.sleep(java.lang.Object, long, int)(Thread.java:-2) waiters=0 in java.util.List d.d.e.c.k.b.f.f(java.lang.String, java.lang.String) for 270.749s

讓我比較難以理解的是LinJwDemoMqtt_1672223154916這個線程是卡在了java.lang.Thread.sleep這裏。難道說我們的代碼裏面會有一個sleep 270秒的操作?搜索完整個代碼都沒有找到sleep的調用,於是只能壓測復現然後使用"kill -3 {pid}"命令強制打印出進程的堆棧,然後在/data/anr/目錄下找到它:

"MQTT Call: LinJwDemoMqtt_1672223154916" prio=5 tid=21 Sleeping
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x132c06f0 self=0x7d42a40800
  | sysTid=1561 nice=0 cgrp=default sched=0/0 handle=0x7d329144f0
  | state=S schedstat=( 2172565480 1496095545 17833 ) utm=151 stm=64 core=5 HZ=100
  | stack=0x7d32811000-0x7d32813000 stackSize=1041KB
  | held mutexes=
  at java.lang.Thread.sleep(Native method)
  - sleeping on <0x07054ef1> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:373)
  - locked <0x07054ef1> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:314)
  at android.net.LocalSocketImpl$SocketOutputStream.flush(LocalSocketImpl.java:185)
  at d.d.e.b.c.b.a(:2)
  at d.d.e.c.k.b.f.g(:-1)
  at d.d.e.c.k.b.f.f(:-1)
  - locked <0x0ab63ad6> (a d.d.e.c.k.b.f)
  at d.d.e.c.k.b.e.a(:-1)
  at d.d.e.c.k.a.a(:2)
  at d.d.e.c.j.d$a.a(:3)
  at d.d.e.c.h.e$a.a(:30)
  at g.b.a.a.a.l.c.d(:12)
  at g.b.a.a.a.l.c.run(:-1)
  at java.lang.Thread.run(Thread.java:764)

發現是卡在了LocalSocket裏面,我們的確會使用localsocket做通訊。翻看LocalSocketImpl的源碼會找到這樣一個醜陋的實現:

// https://cs.android.com/android/platform/superproject/+/android-9.0.0_r60:frameworks/base/core/java/android/net/LocalSocketImpl.java
public void flush() throws IOException {
    FileDescriptor myFd = fd;
    if (myFd == null) throw new IOException("socket closed");

    // Loop until the output buffer is empty.
    Int32Ref pending = new Int32Ref(0);
    while (true) {
        try {
            // See linux/net/unix/af_unix.c
            Os.ioctlInt(myFd, OsConstants.TIOCOUTQ, pending);
        } catch (ErrnoException e) {
            throw e.rethrowAsIOException();
        }

        if (pending.value <= 0) {
            // The output buffer is empty.
            break;
        }

        try {
            Thread.sleep(10);
        } catch (InterruptedException ie) {
            break;
        }
    }
}

有個while true裏面去sleep了10ms。但是這裏和我們看到的270多秒相差甚遠,就算Thread.sleep再怎麼有誤差也差不了這麼多。

由於是開機的時候出現的,考慮可能是時間同步的鍋,可能在sleep前後系統時間改變了。但是翻看日誌發現時間是連續的沒有出現跳變。

schedstat

我連續抓了幾次堆棧,發現schedstat值是在增加的:

// 第一次抓取
"MQTT Call: LinJwDemoMqtt_1672223154916" prio=5 tid=21 Sleeping
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x132c06f0 self=0x7d42a40800
  | sysTid=1561 nice=0 cgrp=default sched=0/0 handle=0x7d329144f0
  | state=S schedstat=( 1808090884 1440374635 15039 ) utm=129 stm=50 core=4 HZ=100

// 第二次抓取
"MQTT Call: LinJwDemoMqtt_1672223154916" prio=5 tid=21 Sleeping
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x132c06f0 self=0x7d42a40800
  | sysTid=1561 nice=0 cgrp=default sched=0/0 handle=0x7d329144f0
  | state=S schedstat=( 2391421933 1559350961 20051 ) utm=165 stm=73 core=3 HZ=100


// 第三次抓取
"MQTT Call: LinJwDemoMqtt_1672223154916" prio=5 tid=21 Sleeping
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x132c06f0 self=0x7d42a40800
  | sysTid=1561 nice=0 cgrp=default sched=0/0 handle=0x7d329144f0
  | state=S schedstat=( 3049001564 1709661634 25792 ) utm=210 stm=94 core=1 HZ=100

這個schedstat其實是Linux裏面的東西,從文檔來看這三個值分別是:

  1. 在cpu上花費的時間(納秒)
  2. 等待運行隊列所花費的時間(納秒)
  3. 此cpu上運行的時間片數

我們也可以用cat命令直接讀取到:

cat /proc/{pic}/task/{tid}/schedstat

從schedstat來看線程佔用的cpu時間片是一直在增加的,所以這個線程並不是一直sleep的。只能說讀取owners_method的時候剛好抓到sleep這個方法了。

由於我們寫入的數據是一個很短的字符串,於是結合LocalSocketImpl的源碼可以猜測是Os.ioctlInt寫入之後pending.value一直大於0。也許是localsocket接收端有問題,又有可能是系統本身在開機的時候某些狀態有問題。

由於復現了幾次,時間都是270、280秒,感覺系統本身的問題概率不大。於是寫了個簡單的測試接收端,發現只要接收端一直不去read數據,發送端flush裏的while循環就一直出不來。

由於我們提供的客戶端sdk裏面使用okhttp封裝了一層localsocket,okhttp的複用連接池裏面socket的生存時間是5分鐘,在生存時間到了之後就會自動回收socket,觸發發送端的flush退出while循環。所以復現的幾次都是卡了270、280多秒接近5分鐘。從恢復時間點附近也找到了這樣的日誌作爲佐證:

12-28 18:30:59.832 1852 2770 W System : A resource failed to call response.body().close().

線程鎖定位

從上面我們只能看到其中的一個線程被localsocket堵住了,但是爲什麼mqtt會不斷斷開呢,我們從堆棧裏面看到這個線程鎖了一個0x0ab63ad6對象,在堆棧裏搜索它,可以看到後面新啓動的mqtt線程都在"waiting to lock <0x0ab63ad6> (a d.d.e.c.k.b.f) held by thread 21":

"MQTT Call: LinJwDemoMqtt_1672223188697" prio=5 tid=41 Blocked
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x13780ef8 self=0x7d42a3fc00
  | sysTid=4307 nice=0 cgrp=default sched=0/0 handle=0x7d30f194f0
  | state=S schedstat=( 9329835 2060251 4 ) utm=0 stm=0 core=2 HZ=100
  | stack=0x7d30e16000-0x7d30e18000 stackSize=1041KB
  | held mutexes=
  at d.d.e.c.k.b.f.f(:-1)
  - waiting to lock <0x0ab63ad6> (a d.d.e.c.k.b.f) held by thread 21
  at d.d.e.c.k.b.e.a(:-1)
  at d.d.e.c.k.a.a(:2)
  at d.d.e.c.a.a(:3)
  at d.d.e.c.j.d.a(:24)
  at d.d.e.c.j.b.a(:1)
  at d.d.e.c.h.e$a.a(:11)
  at g.b.a.a.a.l.c.d(:12)
  at g.b.a.a.a.l.c.run(:-1)
  at java.lang.Thread.run(Thread.java:764)

"MQTT Call: LinJwDemoMqtt_1672223201187" prio=5 tid=42 Blocked
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x131c10b8 self=0x7d44472000
  | sysTid=4730 nice=0 cgrp=default sched=0/0 handle=0x7d30e134f0
  | state=S schedstat=( 23179915 15908085 274 ) utm=1 stm=0 core=3 HZ=100
  | stack=0x7d30d10000-0x7d30d12000 stackSize=1041KB
  | held mutexes=
  at d.d.e.c.k.b.f.f(:-1)
  - waiting to lock <0x0ab63ad6> (a d.d.e.c.k.b.f) held by thread 21
  at d.d.e.c.k.b.e.a(:-1)
  at d.d.e.c.k.a.a(:2)
  at d.d.e.c.a.a(:3)
  at d.d.e.c.j.d.a(:24)
  at d.d.e.c.j.b.a(:1)
  at d.d.e.c.h.e$a.a(:11)
  at g.b.a.a.a.l.c.d(:12)
  at g.b.a.a.a.l.c.run(:-1)
  at java.lang.Thread.run(Thread.java:764)

"MQTT Call: LinJwDemoMqtt_1672223216180" prio=5 tid=43 Blocked
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x13782748 self=0x7d44472c00
  | sysTid=4945 nice=0 cgrp=default sched=0/0 handle=0x7d30d0d4f0
  | state=S schedstat=( 9939123 3184420 14 ) utm=0 stm=0 core=3 HZ=100
  | stack=0x7d30c0a000-0x7d30c0c000 stackSize=1041KB
  | held mutexes=
  at d.d.e.c.k.b.f.f(:-1)
  - waiting to lock <0x0ab63ad6> (a d.d.e.c.k.b.f) held by thread 21
  at d.d.e.c.k.b.e.a(:-1)
  at d.d.e.c.k.a.a(:2)
  at d.d.e.c.a.a(:3)
  at d.d.e.c.j.d.a(:24)
  at d.d.e.c.j.b.a(:1)
  at d.d.e.c.h.e$a.a(:11)
  at g.b.a.a.a.l.c.d(:12)
  at g.b.a.a.a.l.c.run(:-1)
  at java.lang.Thread.run(Thread.java:764)
...

tid=21就是我們之前那個卡在flush的線程。

也就是說mqtt連接成功之後都會調用到localsocket的寫入,寫入之前我們的代碼裏面對代碼塊進行加鎖,然後就都在等第一個線程的flush退出while循環,導致mqtt庫接收不到broker的響應自動斷開。

所以現在已經可以定位是提供的sdk的問題,接下來就需要處理sdk裏面沒有讀取的異常情況。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章