Android进阶宝典 -- 并发编程之线程池

推荐文章:
# Android进阶宝典 -- 并发编程之JMM模型和锁机制

在上文的末尾,为了解决sychronized的阻塞性问题,引出了CAS算法,目的就是为了提高效率从而达到计算准确的目的。其实在Android开发中,一直追求的就是响应速度,因此CAS算法是首选。

那么在CAS算法中是如何保证计算的准确性呢?这里可以做一个总结:

首先CAS算法是会存储主内存中的旧值,然后完成计算之后并不会直接刷入主内存,而是会首先比较主内存和旧值是否一致,如果一致说明主内存的值没有被其他线程修改过,则直接刷入主内存;如果不一致,则工作内存缓存该地址的数据失效,需要重新获取,重新计算。

1 无锁并发与有锁并发

所以,既然加锁之后会影响效率,那么无锁(像CAS)就一定会提高效率吗?其实万物都不是绝对的。

对于sychronized加锁的场景下,因为线程在没有获取锁对象的时候会阻塞等待获取,导致释放CPU资源从而发生线程的上下文切换;而无锁的状态下,线程是一直在运行的,只不过会因为主内存中的值与旧值不一致导致重试,但是线程是一直在高速运行的。

那么CAS一定是适应所有的场景吗?其实不是,对于线程数量少的场景下,例如与CPU核数一致,这种情况下CAS一定是效率最高的;但是如果是10000个线程的场景下,CAS反而效率会降低,因为频繁地重试和重新读取,都会消耗额外的CPU资源

所以CAS是基于乐观锁的思想,我不怕别的线程来修改结果,因为CAS有重试机制能保证计算的准确性;但是sychronized是基于悲观锁的思想,在加锁后不允许其他线程修改内存值,只有当锁释放之后,才能修改。

那么如果创建的线程数超过CPU的核心数,那么CAS算法还会高效吗?其实有了线程池,就能够尽可能地限制最大线程数,保证CAS算法的高效。

2 核心线程数的考量

首先我们需要知道为什么会出现线程池这个东西?首先如果我们有10000个任务,那么肯定不会开辟10000个线程去处理,所以出现了线程池的概念,利用有限的线程数处理无限的任务。

2.1 线程饥饿

虽然线程池能够处理无限的任务,但是如果任务的类型是不一样的,例如有2000个任务,其中A任务类型有1000个,B任务类型有1000个,当前线程池中有10个工作线程。

java 复制代码
private static void testThreadPool(){
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    for (int i = 0; i < 10; i++) {
        executorService.submit(()->{
            System.out.println("上菜--");
        });
    }
    for (int i = 0; i < 10; i++) {
        executorService.submit(()->{
            System.out.println("做菜--");
        });
    }
}

假如工作线程全部都在执行A任务,B任务处理闲置状态,但是B任务会对A任务有调度,或者说有依赖项,可能会导致概念上的"死锁"。例如上面的例子,正常应该是先做菜完事后才能上菜,但是因为执行的先后顺序,导致上菜在等做菜完,但是做菜又不能先执行。因此如果这个时候有一个新的线程执行了做菜的操作,那么上菜就能正常执行了。

所以因为线程饥饿的问题,所以在使用线程池的时候,对于有互相关联的任务来说,不能放在同一线程池中处理,需要新开辟线程池处理。

2.2 核心线程数

当然线程池并不是代表着线程数越多,它的效率就越高,如果一股脑创建一堆线程真正运作的时候反而用不上,才是真正浪费系统资源,所以需要根据我们的业务场景来决定核心线程数的大小。

(1)CPU密集型任务

对于CPU密集型任务,例如for循环,通常采用CPU核数 + 1就能实现CPU的最优利用率,为什么要+1呢,就是因为可能存在系统故障导致线程停止,那么额外的这个线程就能够顶上去。

(2)IO密集型任务

这种一般发生在频繁地读写IO操作,或者从数据库读写数据,这个时候CPU会闲置下来,可以使用多线程来提高效率

因此这里总结一个公式:核心线程数 = CPU核数 * CPU利用率 * 计算时长百分比 / 等待时长百分比

例如发起一个网络请求,从发起请求到拿到结果中间是需要等待时长的,假设等待时长占比为20%,那么计算时长为80%,当前CPU核数为4核,那么核心线程数 = 4 * 100% * 80% / 20% = 16

3 自定义线程池

从上面小节中,大概了解了线程池的工作流程,其实就是一个生产者和消费者的设计思想,主线程通过生产任务,线程池来消费任务。

所以根据上图的思想,设计一个线程池。

3.1 任务队列

任务队列,主要的作用就是存储异步任务提供给线程池调用任务,所以在其内部是维护了一个队列

kotlin 复制代码
//维护异步任务的队列
val dequeTask = ArrayDeque<T>()
val mLock = ReentrantLock()

//条件变量 队列满了
val fullWaitSet = mLock.newCondition()

//队列空了
val emptyWaitSet = mLock.newCondition()

//队列的容量 默认为4个
val dequeSize = AtomicInteger(4)

这个队列随时可能满,随时可能空,因此当队列满的时候,便不能添加任务,需要等到队列不满;当队列空的时候,不能取任务,需要等到队列不为空的时候,因此需要两个条件变量Condition。

kotlin 复制代码
interface IBlockingQueue<T> {

    fun addTask(t: T)
    fun removeTask(): T?
}

对于队列来说,主要有两个操作,一个是取操作,一个是添加操作。

kotlin 复制代码
override fun addTask(t: T) {

    //线程安全操作
    mLock.lock()
    try {

        //判断队列大小是否超限
        while (dequeTask.size >= dequeSize.get()) {
            //需要等到队列不满的时候
            fullWaitSet.await()
        }

        //如果没有超限
        dequeTask.add(t)
        //既然能添加数据,证明队列不是空的了
        emptyWaitSet.signal()
        
    } finally {
        mLock.unlock()
    }
}

override fun removeTask(): T? {

    mLock.lock()

    var target: T? = null
    try {

        //判断队列是否为空
        while (dequeTask.size == 0) {
            //如果为空,需要等到不为空的时候
            emptyWaitSet.await()
        }

        //如果队列不为空,那么可以取数据
        target = dequeTask.removeFirst()
        //既然取出了数据,那么队列一定不是满的,则可以添加数据,通知fullWaitSet可以跳出循环了
        fullWaitSet.signal()
    } finally {
        mLock.unlock()
    }
    return target
}

所以在添加或者获取的时候,依赖两个条件变量,当一方能够执行时,通知另一方执行,两者属于相互依赖,有消费就有添加

3.2 线程池

从小节开头的图中可以看到,除了任务队列之外,还有就是一个线程集合,当执行任务时,首先会分配给线程集合中的核心线程执行。

kotlin 复制代码
class MyThreadPool {

    //任务队列
    private val blockingQueue: MyBlockingQueue<Runnable> by lazy {
        MyBlockingQueue()
    }

    //线程集合
    private val works: HashSet<Work> by lazy {
        HashSet()
    }

    //注意这里可以动态配置,demo中暂时写死了
    private val coreSize = AtomicInteger(4)

    fun execute(task: Runnable) {

        //如果当前有空闲的核心线程可以使用
        if (works.size < coreSize.get()) {
            val work = Work(task)
            work.start()
            works.add(work)
        } else {
            //否则就往任务队列中塞
            blockingQueue.addTask(task)
        }

    }

    inner class Work(var task: Runnable?) : Thread() {

        override fun run() {
            super.run()
            while (task != null) {
                try {
                    task?.run()
                }finally {
                    task = blockingQueue.removeTask()
                }
            }
        }
    }
}

看下execute方法,当有空闲线程时,任务会交给线程立即执行,而如果没有空闲的线程,那么就会塞进任务队列中。

我们看下Work这个内部类,是继承自Thread,当执行run方法时,我们可以看到是有一个while循环的,会不断从任务队列中去任务执行。

其实对于线程池的使用,前面我们在介绍OkHttp的时候,启异步请求方法中就使用到了高并发、高吞吐量的线程池,并配合阻塞队列一起使用,在实际的项目开发中,我们可能暂时用不到线程池,但是在一些框架源码中却是经常见到,这也有利于我们理解其中的思想。

相关推荐
C4rpeDime32 分钟前
自建MD5解密平台-续
android
鲤籽鲲2 小时前
C# Random 随机数 全面解析
android·java·c#
m0_548514776 小时前
2024.12.10——攻防世界Web_php_include
android·前端·php
凤邪摩羯6 小时前
Android-性能优化-03-启动优化-启动耗时
android
凤邪摩羯6 小时前
Android-性能优化-02-内存优化-LeakCanary原理解析
android
喀什酱豆腐7 小时前
Handle
android
m0_748232928 小时前
Android Https和WebView
android·网络协议·https
m0_748251729 小时前
Android webview 打开本地H5项目(Cocos游戏以及Unity游戏)
android·游戏·unity
m0_7482546611 小时前
go官方日志库带色彩格式化
android·开发语言·golang
zhangphil11 小时前
Android使用PorterDuffXfermode模式PorterDuff.Mode.SRC_OUT橡皮擦实现“刮刮乐”效果,Kotlin(2)
android·kotlin