一个小故事讲明白进程、线程、Kotlin 协程到底啥关系?

·  阅读 3172

前言

协程系列文章:

相信稍微接触过Kotlin的同学都知道Kotlin Coroutine(协程)的大名,甚至有些同学认为重要到"无协程,不Kotlin"的地步,吓得我赶紧去翻阅了协程源码,同时也学习了不少博客,博客里比较典型的几个说法:

  • 协程是轻量级线程、比线程耗费资源少
  • 协程是线程框架
  • 协程效率高于线程
  • ...

一堆术语听起来是不是很高端的样子?这些表述正确吗?妥当吗?你说我学了大半天,虽然我也会用,但还是没弄懂啥是协程...
为了彻底弄懂啥是协程,需要将进程、线程拉进来一起pk。
通过本篇文章,你将了解到:

1、程序、进程、CPU、内存关系
2、进程与线程的故事
3、线程与Kotlin协程的故事
4、Kotlin 协程的使命

1、程序、进程、CPU、内存关系

image.png

如上图,平时我们打包好一个应用,放在磁盘上,此时我们称之为程序或者应用,是静态的。也就是咱们平常说的:我下载个程序,你传给apk给我,它们都是程序(应用)。
当我们执行程序(比如点击某个App),OS 会将它加载进内存,CPU 从内存某个起始地址开始读取指令并执行程序。
程序从磁盘上加载到内存并被CPU运行期间,称之为进程。因此我们通常说某个应用是否还在存活,实际上说的是进程是否还在内存里;也会说某某程序CPU占用率太高,实际上说的是进程的CPU占用率。
而操作系统负责管理磁盘、内存、CPU等交互,可以说是大管家。

2、进程与线程的故事

接下来我们以一个故事说起。

上古时代的合作

image.png

在上古时候,一个设备里只有一个CPU,能力比较弱,单位时间内能够处理的任务量有限,内存比较小,能加载的应用不多,相应的那会儿编写的程序功能单一,结构简单。

OS 说:"大家都知道,我们的情况比较具体,只有一个CPU,内存也很小,而现在有不少应用想要占用CPU和内存,无规矩不成方圆,我现在定下个规矩:"

每个应用加载到内存后,我将给他安排内存里的一块独立的空间,并记录它的一些必要信息,最后规整为一个叫进程的东西,就是代表你这个应用的所有信息,以后我就只管调度进程即可。
并且进程之间的内存空间是隔离的,无法轻易访问,特殊情况需要经过我的允许。

应用(程序)说:"哦,我知道了,意思就是:进程是资源分派的基本单位嘛"
OS 说:"对的,悟性真好,小伙子。"

规矩定下了,大家就开始干活了:

1、应用被加载到内存后,OS分派了一些资源给它。
2、CPU 从内存里逐一取出并执行进程。
3、其它没有得到CPU青睐的进程则静候等待,等待被翻牌。

中古时代的合作

一切都有条不紊的进行着,大家配合默契,其乐融融,直到有一天,OS 发现了一些端倪。
他发现CPU 在偷懒...找到CPU,压抑心中的愤怒说到:
"我发现你最近不是很忙哎,是不是工作量不饱和?"
CPU 忙不迭说到:"冤枉啊,我确实不是很忙,但这不怪我啊。你也知道我最近升级了频率,处理速度快了很多,进程每次给我的任务我都快速执行完了,不过它们却一直占用我,不让我处理其它进程的,我也是没办法啊。"
OS 大吃一惊到:"大胆进程,居然占着茅坑不拉屎!"
CPU 小声到:"我又不是茅坑..."

OS 找来进程劈头盖脸训斥一道:"进程你好大的胆,我之前不是给你说请CPU 做事情要讲究一个原则:按需占有,用完就退。你把我话当耳边风了?"
进程直呼:"此事与我无关啊,你知道的我最讲原则了,你之前说过对CPU 的使用:应占尽占。我现在不仅要处理本地逻辑,还要从磁盘读取文件,这个时候我虽然不占用CPU,但是我后面文件读结束还是需要他。"

OS 眉头紧皱,略微思索了一下对进程和CPU道:"此事前因后果均已知悉,容我斟酌几日。"
几天后,OS 过来对他俩说:"我现在重新拟定一个规则:进程不能一直占用CPU到任务结束为止,需要规定占用的时间片,在规定的时间片内进程能完成多少是多少,时间一到立即退出CPU换另一个进程上,没能完成任务的进程等下个轮到自己的时间片再上"
进程和CPU 对视一眼,立即附和:"谨遵钧令,使命必达!"

近现代的合作

自从实行新规定以来,进程们都有机会抢占CPU了,算是雨露均沾,很少出现某进程长期霸占CPU的现象了,OS 对此很是满意。

一则来自进程的举报打破这黎明前的宁静。
OS 收到一则举报:"我进程实名举报CPU 偷懒。"
OS 心里咯噔一跳,寻思着咋又是CPU,于是叫来CPU 对簿公堂。
CPU 听到OS 召唤,暗叫不妙,心里立马准备了一套说辞。
OS 对着CPU 和 进程说:"进程说你偷懒,你在服务进程的时间片内无所事事,我希望你能给我一个满意的答复。"
CPU 一听这话,心里一阵鄙视,果不出我所料,就知道你问这事。虽然心里诽腹不已,脸上却是郑重其事道:"这事是因为进程交给我的任务很快完成了,它去忙别的事了,让我等等他。"
OS 诧异道:"你这么快就将进程的任务处理完成了?"
CPU 面露得以之色道:"你知道的我一直追求进步,这不前阵子又升级了一下嘛,处理能力又提升了。如果说优秀是一种原罪的话,那这个罪名由我承担吧,再如果..."
OS 看了进程一眼,对CPU 说:"行行行,打住,此事确实与你无关。进程虽然你误会了CPU,但是你提出的问题确实是一个好的思考点,这个下来我想个方案,回头咱们评审一下。"

一个月后,OS 将进程和CPU召集起来,并拿出方案说:"我们这次将进行一次大的调整,鉴于CPU 处理能力提升,他想要承担更多的工作,而目前以进程为单位提交任务颗粒度太大了,需要再细化。我建议将进程划分为若干线程,这些线程共享进程的资源池,进程想要执行某任务直接交给线程即可,而CPU每次以线程为单位执行。接下来,你们说说各自的意见吧。"
进程说到:"这个方案很优秀,相当于我可以弄出分身,让各个分身干各项任务,处理UI一个线程,处理I/O是另一个线程,处理其它任务是其它线程,我只需要分派各个任务给线程,剩下的无需我操心了。CPU 你觉得呢?"
CPU 心底暗道:"你自己倒是简单,只管造分身,脏活累活都是我干..."
表面故作沉重说到:"这个改动有点大,我现在需要直接对接线程,这块需要下来好好研究一下,不过问题不大。"
进程补充道:"CPU 你可以要记清楚了,以后线程是CPU 调度的基本单位了。"
CPU 应道:"好的,好的,了解了(还用你复述OS 的话嘛...)。"

规矩定下了,大家热火朝天地干活。

image.png

进程至少有一个线程在运行,其余按需制造线程,多个线程共用进程资源,每个线程都被CPU 执行。

新时代的合作

OS 照例视察各个模块的合作,这天进程又向它抱怨了:"我最近各个线程的数据总是对不上,是不是内存出现了差错?"
OS 愣了一下,说到:"这问题我知道了,还没来得及和你说呢。最近咱们多放了几个CPU 模块提升设备的整体性能,你的线程可能在不同的CPU上运行,因此拿到的数据有点问题。"
进程若有所思道:"以前只有一个CPU,各个进程看似同时运行,实则分享CPU时间片,是并发行为。现在CPU 多了,不同的线程有机会同时运行,这就是真并行了吧。"
OS 道:"举一反三能力不错哦,不管并行还是并发,多个线程共享的数据有可能不一致,尤其加入了多CPU后,现象比较明显,这就是多线程数据安全问题。底层已经提供了一些基本的机制,比如CPU的MESI,但还是无法完全解决这问题,剩下的交给上层吧。"
进程道:"了解了,那我告诉各个线程,如果他们有共享数据的需求,自己协商解决一下。"
进程告知线程自己处理线程安全问题,线程答到:"我只是个工具人,谁用谁负责处理就好。"
一众编程语言答到:"我自己来处理吧。"
多CPU 如下,每个线程都有可能被其它CPU运行。

image.png

3、线程与Kotlin协程的故事

Java 线程调用

底层一众大佬已经将坑踩得差不多了,这时候得各个编程语言出场了。
C 语言作为骨灰级人物远近闻名,OS、驱动等都是由他编写,这无需介绍了。
之后如雨后春笋般又冒出了许多优秀的语言,如C++、Java、C#、Qt 等,本小结的主人公:Java。
Java 从小目标远大,想要跨平台运行,借助于JVM他可以实现这个梦想,每个JVM 实例对应一个进程,并且OS 还给了他操作线程的权限。
Java 想既然大佬这么支持,那我要撸起袖子加油干了,刚好在Android 上接到一个需求:

通过学生的id,向后台(联网)查询学生的基本信息,如姓名、年龄等。

Java 心想:"这还不简单,且看我猛如虎的操作。"
先定义学生Bean类型:

public class StudentInfo {
    //学生id
    private long stuId = 999;
    private String name = "fish";
    private int age = 18;
}
复制代码

再定义一个获取的动作:

    //从后台获取信息
    public StudentInfo getWithoutThread(long stuId) {
        try {
            //模拟耗时操作
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return new StudentInfo(); 
    }
复制代码

信心满满地运行,却被现实无情打脸,只见控制台显目的红色:

不能在主线程进行网络请求。

同步调用

Java 并不气馁,这问题简单,我开个线程取获取不就得了?

    Callable<StudentInfo> callable = new Callable<StudentInfo>() {
        @Override
        public StudentInfo call() throws Exception {
            try {
                //模拟耗时操作
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return new StudentInfo();
        }
    };

    public StudentInfo getStuInfo(long stuId) {
        //定义任务
        FutureTask<StudentInfo> futureTask = new FutureTask<>(callable);
        //开启线程,执行任务
        new Thread(futureTask).start();
        try {
            //阻塞获取结果
            StudentInfo studentInfo = futureTask.get();
            return studentInfo;
        } catch (ExecutionException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return null;
    }
复制代码

而后,再在界面上弹出学生姓名:

 JavaStudent javaStudent = new JavaStudent();
        StudentInfo studentInfo = javaStudent.getWithoutThread(999);
        Toast.makeText(this, "学生姓名:" + studentInfo.getName(), Toast.LENGTH_LONG).show();
复制代码

刚开始能弹出Toast,然而后面动不动UI就卡顿,甚至出现ANR 弹窗。
Java 百思不得其解,后得到Android 本尊指点:

Android 主线程不能进行耗时操作。

Java 说到:"我就简单获取个信息,咋这么多限制..."
Android 答到:"Android 通常需要在主线程更新UI,主线程不能做过多耗时操作,否则影响UI 渲染流畅度。不仅是Android,你Java 本身的主线程(main线程)通常也不会做耗时啊,都是通过开启各个线程去完成任务,要不然每一步都要主线程等待,那主线程的其它关键任务就没法开启了。"
Java 沉思道:"有道理,容我三思。"

异步调用与回调

Java 果不愧是编程语言界的老手,闭关几天就想出了方案,直接show us code:

    //回调接口
    public interface Callback {
        void onCallback(StudentInfo studentInfo);
    }

    //异步调用
    public void getStuInfoAsync(long stuId, Callback callback) {
        new Thread(() -> {
            try {
                //模拟耗时操作
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            StudentInfo studentInfo = new StudentInfo();
            if (callback != null) {
                //回调给调用者
                callback.onCallback(studentInfo);
            }
        }).start();
    }
复制代码

在调用耗时方法时,只需要将自己的凭证(回调对象)传给方法即可,调用者不管方法里具体是咋实现的,才不管你开几个线程呢,反正你有结果通过回调给我。
调用者只需要在需要的地方实现回调接收即可:

        JavaStudent javaStudent = new JavaStudent();
        javaStudent.getStuInfoAsync(999, new JavaStudent.Callback() {
            @Override
            public void onCallback(StudentInfo studentInfo) {
                //异步调用,回调从子线程返回,需要切换到主线程更新UI
                runOnUiThread(() -> {
                    Toast.makeText(TestJavaActivity.this, "学生姓名:" + studentInfo.getName(), Toast.LENGTH_LONG).show();
                });
            }
        });
复制代码

异步调用的好处显而易见:

1、不用阻塞调用者,调用者可继续做其它事情。
2、线程没有被阻塞,相比同步调用效率更高。

缺点也是比较明显:

1、没有同步调用直观。
2、容易陷入多层回调,不利于阅读与调试。
3、从内到外的异常处理缺失传递性。

Kotlin 协程毛遂自荐

Java 靠着左手同步调用、右手异步调用的左右互搏技能,成功实现了很多项目,虽然异步调用有着一些缺点,但瑕不掩瑜。
这天,Java 又收到需求变更了:

通过学生id,获取学生信息,通过学生信息,获取他的语文老师id,通过语文老师id,获取老师姓名,最后更新UI。

Java 不假思索到:"简单,我再嵌套一层回调即可。"

    //回调接口
    public interface Callback {
        void onCallback(StudentInfo studentInfo);
        //新增老师回调接口
        default void onCallback(TeacherInfo teacherInfo){}
    }

    //异步调用
    public void getTeachInfoAsync(long stuId, Callback callback) {
        //先获取学生信息
        getStuInfoAsync(stuId, new Callback() {
            @Override
            public void onCallback(StudentInfo studentInfo) {
                //获取学生信息后,取出关联的语文老师id,获取老师信息
                new Thread(() -> {
                    try {
                        //模拟耗时操作
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    
                    TeacherInfo teacherInfo = new TeacherInfo();
                    if (callback != null) {
                        //老师信息获取成功
                        callback.onCallback(teacherInfo);
                    }
                }).start();
            }
        });
    }
复制代码

眼看Java 一下子实现了功能,Android再提需求:

通过老师id,获取他所在的教研组信息,再通过教研组id获取教研组排名...

Java 抗议道:"哪有这么奇葩的需求,那我不是要无限回调吗,我可以实现,但不好维护,过几天我自己看都看不懂了。"
Android:"不就是几个回调的问题嘛,亏你还是老员工,实在不行,我找其他人。"
Java:"...我再想想。"

正当Java 一筹莫展之际,吃饭时刚好碰到了Kotlin,Java 难得有时间和这位新入职的小伙伴聊聊天,发发牢骚。
Kotlin 听了Java 的遭遇,表达了同情并劝说Java 赶紧离职,Android 这块不适合他。

Kotlin 随后找到Android,略微紧张地说:"吾有一计,可安天下。"
Android 对于毛遂自荐的人才是非常欢迎的,问曰:"计将安出"
Kotlin 随后激动到:协程。
Android 诧异道:"协程,旅游?"

image.png

Kotlin 赶紧道:"非也,此协程非彼携程...而是它"

jj.png

Android 说:"看这肌肉挺大的,想必比较强,请开始你的表演吧。"
Koltin 立马展示自己。

class StudentCoroutine {
    private val FIXED_TEACHER_ID = 888
    fun getTeachInfo(act: Activity, stuId: Long) {
        GlobalScope.launch(Dispatchers.Main) {

            var studentInfo: StudentInfo
            var teacherInfo: TeacherInfo? = null

            //先获取学生信息
            withContext(Dispatchers.IO) {
                //模拟网络获取
                Thread.sleep(2000)
                studentInfo = StudentInfo()
            }
            //再获取教师信息
            withContext(Dispatchers.IO) {
                if (studentInfo.lanTechId.toInt() === FIXED_TEACHER_ID) {
                    //模拟网络获取
                    Thread.sleep(2000)
                    teacherInfo = TeacherInfo()
                }
            }
            //更新UI
            Toast.makeText(act, "teacher name:${teacherInfo?.name}", Toast.LENGTH_LONG).show()
        }
        Toast.makeText(act, "主线程还在跑...", Toast.LENGTH_LONG).show()
    }
}
复制代码

外部调用:

    var student = StudentCoroutine()
    student.getTeachInfo(this@MainActivity, 999)
复制代码

Android 一看,大吃一惊:"想不到,语言界竟然有如此厚颜无耻之...不对,如此简洁的写法。"
Kotlin 道:"协程这概念早就有了,其它兄弟语言Python、Go等也实现了,我也是站在巨人的肩膀上,秉着解决用户痛点的思路来设计的。"
Android 随即大手一挥道:"就冲着你这简洁的语法,今后Android 业务你来搞吧,希望你能够担起重担。"
Kotlin 立马道:"没问题,我本身也是跨平台的,只是Java 那边...。"
Android:"这个你无需顾虑,Java 的工作我来做,成年人应该知道这世界是残酷的。"
Java 听到Kotlin 逐渐蚕食了自己在Android上的业务,略微生气,于是看了Kotlin 的写法,最后长舒一口气:"确实比较简洁,看起来功能阻塞了主线程,实际并没有。其实就是 用同步的写法,表达异步的调用。"
Koltin :"知我者,老大哥Java 也。"

4、Kotlin 协程的使命

通过与Java 的比对,大家也知道了协程最大的特色:

将异步编程同步化。

当然还有一些特点,如异常处理、协程取消等。
再回过头来看看上面的疑问。

1、协程是轻量级线程、比线程耗费资源少 这话虽然是官方说的,但我觉得有点误导的作用,协程是语言层面的东西,线程是系统层面的东西,两者没有可比性。
协程就是一段代码块,既然是代码那就离不开CPU的执行,而CPU调度的基本单位是线程。

2、协程是线程框架 协程解决了异步编程时过多回调的问题,既然是异步编程,那势必涉及到不同的线程。Kotlin 协程内部自己维护了线程池,与Java 线程池相比有些优化的地方。在使用协程过程中,无需关注线程的切换细节,只需指定想要执行的线程即可,从对线程的封装这方面来说这说话也没问题。

3、协程效率高于线程 与第一点类似,协程在运行方面的高效率其实换成回调方式也是能够达成同样的效果,实际上协程内部也是通过回调实现的,只是在编译阶段封装了回调的细节而已。因此,协程与线程没有可比性。

阅读完上述内容,想必大家都知道进程、线程、协程的关系了,也许大家还很好奇协程是怎么做到不阻塞调用者线程的?它又是怎么在获取结果后回到原来的位置继续执行呢?线程之间如何做到丝滑般切换的?
不要着急,这些点我们一点点探秘,下篇文章开始徒手开启一个协程,并分析其原理。
Kotlin 源码阅读需要一定的Kotlin 基础,尤其是高阶函数,若是这方面还不太懂的同学可以查阅之前的文章:Kotlin 高阶函数从未如此清晰 系列

本文基于Kotlin 1.5.3,文中完整Demo请点击

coroutine_.gif

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android/Kotlin

1、Android各种Context的前世今生
2、Android DecorView 必知必会
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分发全套服务
6、Android invalidate/postInvalidate/requestLayout 彻底厘清
7、Android Window 如何确定大小/onMeasure()多次执行原因
8、Android事件驱动Handler-Message-Looper解析
9、Android 键盘一招搞定
10、Android 各种坐标彻底明了
11、Android Activity/Window/View 的background
12、Android Activity创建到View的显示过
13、Android IPC 系列
14、Android 存储系列
15、Java 并发系列不再疑惑
16、Java 线程池系列
17、Android Jetpack 前置基础系列
18、Android Jetpack 易学易懂系列
19、Kotlin 轻松入门系列

分类:
Android
收藏成功!
已添加到「」, 点击更改