likes
comments
collection
share

记一次「线程优化」导致的业务异常

作者站长头像
站长
· 阅读数 28

背景

公司项目的APP里使用了许多三方SDK,这些SDK的代码质量参差不齐,里面不乏通过new Thread直接创建线程的代码,而且还很频繁,这样直接创建线程有两个缺点:不受控风险频繁创建开销大。导致的最明显的结果就是App的 oom 率一直降不下来,尤其性能较差的低端机。

所以前段时间针对App做了一次线程优化,简单来讲就是通过ASM插桩,将APP中线程及线程池统一收敛到由我们提供的线程池中,避免各业务方重复创建线程或创建不受控的单一线程,通过统一的线程池管理,限制app里的线程数量,提升应用的运行效率。

为了最大程度的降低线程优化对业务的影响,我们对业务方使用的线程池按照包名进行了分类,同时对手机系统版本以及手机品牌加以区分,根据服务端下发的配置对不同手机品牌、不同手机系统以及不同业务灰度执行优化策略。

经过紧锣密鼓的开发、测试工作后,功能如期上线,并针对一些低端机开始灰度,上线后的效果也很明显,app的平均运行线程数量减少了几十条,低端机的OOM也降了下来。

原以为此次优化到此圆满结束,但不出意外的话,要出意外了,俗话说得好:

祸兮福所倚,福兮祸所伏。

我们app里有这样一个业务场景:app读取本地的文件,本地文件不可用时就尝试从服务端拉取文件,拉取成功后缓存到本地,app再次读取本地的文件进行最终的展示,文件内容的展示使用ViewPager,为了保证用户浏览的流畅性,应用会预加载相邻10页的数据。整个业务流程大概是这样的:

记一次「线程优化」导致的业务异常

其中,请求网络数据缓存到本地、读取本地文件的线程操作在两个线程池进行,我们暂且称之为NetThreadPoolLocalThreadPool,按照我们的线程优化配置,这两个线程池中的线程会被我们提供的IO线程池代理、收敛。

// App业务层统一的IO线程池
// maxIOThreadSize 由线程优化统一配置
val ioThreadPool = ThreadPoolExecutor(maxIOThreadSize, maxIOThreadSize, 30L, TimeUnit.SECONDS, LinkedBlockingDeque(), ioThreadFactory)

有..有bug?!

线程优化功能上线后,偶尔有用户反馈,说上面提到的页面数据加载的特别慢,从后台用户的日志上看,用户反馈的时间节点确实存在网络波动,以为是用户的网络问题导致网络资源加载慢,但反馈的用户普遍表示老版本没有这个问题。这个页面的代码最近几个版本迭代也都没有调整,怀疑是线程优化导致的。

写一个demo来验证下弱网环境下将网络任务线程、本地任务线程统一收敛到一个线程池后会不会有问题,网络任务我们简单使用OkHttp设置Dispatcher统一代理到我们提供的IO线程池中:

// 线程池中的最大个数为 5
private val maxIOThreadSize = 5
// io线程池
val ioThreadPool = ThreadPoolExecutor(maxIOThreadSize, maxIOThreadSize, 30L, TimeUnit.SECONDS, LinkedBlockingDeque(), ioThreadFactory)

private val okHttpClient = OkHttpClient.Builder()
    // 将OkHttp发起的网络请求分发至我们提供的io线程池
    .dispatcher(Dispatcher(ioThreadPool))
    .build()

// 发起网络任务
private fun fetchDataFromRemote(
    username: String,
    password: String,
) {
    // 记录网络请求任务发起时间
    Log.d("ioThreadPool", "start fetchDataFromRemote")
    val body = FormBody.Builder()
        .addEncoded("username", username)
        .addEncoded("password", password)
        .build()
    val request = Request.Builder()
        .url("$baseUrl user/login")
        .post(body)
        .build()
    okHttpClient.newCall(request).enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
            // 记录网络请求失败时间
            Log.d("ioThreadPool", "fetchDataFromRemote fail")
        }

        override fun onResponse(call: Call, response: Response) {
            // 记录网络请求失败时间
            Log.d("ioThreadPool", "fetchDataFromRemote success")
        }
    })
}
// 模拟本地任务
private fun loadDataFromLocal() {
    // 记录本地任务发起时间
    Log.d("ioThreadPool", "start loadDataFromLocal")
    ioThreadPool.execute {
        // 记录本地任务开始执行时间
        Log.d("ioThreadPool", "loading data from local...")
    }
}

private fun testThreadPool() {
    for (i in 0..4) {
        // 1.发起5条网络请求,将线程池打满
        fetchDataFromRemote("test", "test")
    }
    // 2.发起本地任务请求
    loadDataFromLocal()
}

为了方便复现,我们将线程池的核心线程数量最大线程数量设定为5个,然后在testThreadPool()方法中,先发起5个网络请求任务,将线程池打满,然后发起1个不依赖网络环境的本地任务,使用Log工具分别记录下网络请求任务执行时间、结束时间以及发起本地任务的时间以及真正开始执行本地任务的时间。最后打开Charles,模拟弱网环境,运行程序后调用testThreadPool()方法,结果如下:

记一次「线程优化」导致的业务异常

可以看到,从本地任务发出执行命令的16:14:45.887��始,到本地任务真正被执行的16:14:56.133,时间已经过去了10s左右,这显然是不符合我们的预期的,我们来回顾下线程池的相关知识。

线程池小课堂

线程池ThreadPoolExecutor的通用构造函数包含以下几个参数:

public ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
);
  • corePoolSize:核心线程数,当有新任务时,如果线程池中线程数没有达到核心线程数,则会创建新的线程执行任务,否则将其放入阻塞队列。如果线程池中华存活的线程数总是大于核心线程数,则应该考虑调大corePoolSize
  • maximumPoolSize:最大线程数,当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务,否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过keepAliveTime之后,就应该退出,避免资源浪费
  • KeepAliveTime:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为0,表示多余的空闲线程会被立即终止
  • workQueue:存储等待运行的任务的阻塞队列
  • TimeUnit:时间单位
  • threadFactory:线程池中创建线程的工厂
  • RejectedExecutionHandler:当阻塞队列和线程池都满了时的拒绝策略

线程池管理任务的流程如下图:

记一次「线程优化」导致的业务异常

业务场景存在的问题

所以我们上面的业务流程执行起来是这个样子的:

记一次「线程优化」导致的业务异常

虽然本地任务并不依赖网络资源,但是因为同属一个线程池的缘故,本地任务会和网络任务竞争线程资源,导致本地任务无法及时执行,往往需要等待网络任务执行完毕、线程空闲时才能得以执行。弱网环境下,往往需要等待到网络超时...这个时长是以秒计的,用户的感知相当明显。最终的结果就是弱网环境下,页面的加载速度被拖累,也就是用户反馈的页面加载慢。

解决办法

最简单快速的办法就是调大我们的maxIOThreadSize,减少不同任务之间的线程竞争,但这又会影响线程收敛的效果,所以一味的增大maxIOThreadSize并不可取。

上面的场景是因为弱网环境下,网络任务长期占用线程等待超时,堵塞了本地不依赖网络环境的本地任务,所以我们在线程优化时,区分网络任务线程和本地任务线程,将他们分开代理,来解决上面出现的问题。

还是以我们上面的demo为例,我们将OkHttp的请求代理到A线程池,将本地任务代理到B线程池,最后的结果如下:

记一次「线程优化」导致的业务异常

这次本地任务就没有被阻塞,执行的很及时。

反思 & 总结

这个问题的产生暴露了我们在线程优化过程中的一个漏洞,即只关注了优化后对内存方面的改善,忽略了对正常业务流程的影响。导致了上线后产生客诉才意识到问题,然后定位问题、做出应对,响应速度也太慢了些... 本质上,线程优化是一个牵一发而动全身的行为,难以避免的会对业务产生影响。但是在开发时却忽视了这方面的影响,缺乏线程池的有效监控。

那么,如何监控线程池呢?需要关注哪些方面呢?

监控线程池,无非就是检测线程池任务的执行时长,设置告警阈值,任务执行时长超过阈值时,触发告警策略,主动上报异常。所以核心问题就变成了如何检测任务的执行时长。 最简单直接的方法是封装一个自定义的 RunnableCallable 任务,这样就可以在任务执行前后添加时间记录逻辑,代码如下:

class TimedTask(
    private val task: Runnable,
): Runnable {

    override fun run() {
        val startTime = System.nanoTime()
        try {
            task.run()
        } finally {
            val endTime = System.nanoTime()
            val duration = endTime - startTime
            
            // 如果任务的执行时长超过设定阈值,主动上报
            if (duration > maxTaskLimitDuration) {
                reportTaskOverTime()
            }
        }
    }

}

我是沈剑心,侠肝义胆沈剑心,我们下次再见~

仍然要对代码保持敬畏之心,salute!🫡

转载自:https://juejin.cn/post/7390413094444777472
评论
请登录