线程池踩坑

Posted by keys961 on March 4, 2019

1. 问题

最近发生一个问题,当节点主备复制开启的时候,客户端疯狂超时,集群会突然脑裂(心跳不通了),而且之前的压测都没有出现这种情况,所以要查看原因。

2. 测试

结果客户端多次压测,发现服务端突然自己就卡住了,很明显STW,连上VisualVM监控,发现压测过程中,堆内存直接线性增长了,并且GC日志(使用G1回收)出现了:

2019-03-04T15:16:45.918+0800: 43.851: [Full GC (Allocation Failure) 2019-03-04T15:16:45.918+0800: 43.851: [Heap Dump (before full gc): , 86.9621601 secs] 8191M->4362M(8192M), 100.6275291 secs]

明显Full GC的问题,实锤了。

这次很聪明的先在Full GC前dump了堆内存。

3. 检测

jhat不好使,贼慢,就用了Eclipse MAT。

Histogram显示出最大项居然是Object[],WTF?

Figure1

然后进行泄漏检测,发现1个ThreadPoolExecutor居然占了接近2GB的内存,不可接受,点开一看就发现自己写了一个非常低级的错误,看红圈就知道了。

Figure2

Figure3

4. REVIEW: ThreadPoolExecutor

这东西很熟悉了,不过再回顾一下。

这玩意初始化时会有以下几个参数:

  • corePoolSize
  • maxPoolSize
  • keepAliveTime & timeUnit
  • workQueue
  • threadFactory
  • rejectHandler

除了threadFactory不那么重要外,其它都蛮重要的。

代码规范:不要用工厂方法创建,直接用构造函数

这些参数组合可以构建不同类型的线程池,如单一线程池、固定数量多线程池、缓冲线程池等等

往线程池中添加任务的流程大家都很了解了,这里再重新回顾以下:

1
2
3
4
5
6
7
8
9
10
if (corePool.availableThread() > 0) {
	corePool.pickThread().exec(task);
} else if (workQueue.isFull() == false) {
	workQueue.put(task); //threads in core pool & max pool will fetch tasks from the work queue
} else if (maxPool.availableThread() > 0) {
    newThread = createThreadAndExec(task);
    maxPool.put(newThread); // thread will be alive determined by the keepAliveTime
} else {
    rejectHandler.reject(task); 
}

5. 问题所在

根据之前的设计,请求先由sc-boss线程接管,由于多路复用(可参考Netty的线程模型),分配到sc-worker线程。由于请求需要耗时,所以必须将请求再独立分到其它线程池中,这里有:

  • 对于普通请求,分配到request-scheduler
  • 对于复制请求,分配到replication-scheduler

不这么做,耗时的请求将会一直占用sc-worker线程,造成该池队列堆积,新请求无法及时处理,且两类请求互相抢占,很可能造成某类操作超时(超时指仍然能连接,但是publisher#subscribe迟迟收不到应答,实测复制请求会严重超时)等其它问题。此外,还可以让调试和性能监测更加容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Flux<Resp> handleReq(Flux<Req> reqs) {
    // in sc-worker
    return Flux.create(emitter -> {
        reqs.publishOn(reqScheduler)
            .onBackpressureBuffer()
            .subscribe(req -> {
                // handle reqs in request-scheduler
                // will be locked when exec blockingQueue.put()
                // ...
            }, throwable -> {
                // handle error in sc-worker
                // no-lock & wait free
            }, () -> {
                // handle other when reqs flux is terminated in request-scheduler
                // ...
            })
    });
    // The Flux will be returned immediately.
}

无关的话说完,其实修复这个问题很简单,只要给workerQueue设置一个上限参数,然后这里使用Aborted拒绝策略,抛出异常,以便于subscriber处理就行了。

结果是没有Full GC,一切变得正常了。

Figure4

而实际上,之前的压测是将请求分批(30个)发送给服务端的,而目前的压测是将请求一个一个发出去的。很明显,线程池中队列的元素数量和请求数量成正比,前者明显要少很多,所以掩盖了这个问题。

6. 小结

细节最重要,有时候知道大致原理不代表真的懂,要对每个参数了如指掌。(这种低级错误不应该)