你学的越多,不懂的东西反而越多~
前言
以前觉得 C++ 并没有什么复杂的,不就是 C 语言加上类定义、模板、容器、算法函数这些就可以了吗,只要我不用,它就难不倒我,用到了查查文档也就搞定了,真的是年少轻狂啊。
随着学习的深入渐渐发现,即使抛开那些算法函数、那些冗长的模板,单单是 C++ 核心的概念和类型就够喝上好几壶的,随便罗列几个,像 std::furnitrue
、std::memory_order
、std::packaged_task
等等这些,之前都没听说过,特别是C++20的协程,到现在还是一头雾水。
C++ 缺少了 C 语言的纯粹,总是喜欢在编译时加点料,但是这个协程加的料超多,一时间还有点接受不了。
不过第一次听说协程这个词是在 Lua 中,全称被叫做协同程序,记得没错是在 《Lua程序设计》这本书中看到的,里面专门有一章是讲coroutine的,并且在 Lua 中定义和使用协程很方便,所以决定先复习一下 Lua 中的协程,然后对比着 C++的协程来进行拓展学习。
进程 vs 线程 vs 协程
这三者常常被拿来比较,而引入多进程、多线程、多协程有一个简单而纯粹的目的,那就是榨干CPU,不过这三者侧重还有所不同。
进程是资源分配最小单位,每个进程都有独立的地址空间,来维护代码段、堆栈段和数据段,但是创建和切换进程的开销较大,可以在多台物理机和多核CPU上提高效率,依靠管道(pipe)、命名管道(named pipe/FIFO)、信号量(semophore)、消息队列(message queue)、信号(sinal)、共享内存(shared memory)、套接字(socket)、全双工管道等途径来进行通信。
线程是任务调度和执行的最小单位,没有独立的地址空间,但有独立的运行栈和程序计数器(PC),创建和切换线程的开销相比进程来说要小得多,线程之间通信更加方便,除了可以使用进程间通信的方式,还可以简单地通过共享全局变量,静态变量等进行通信,但是需要锁机制、信号量机制、信号机制来控制线程间互斥。
协程这个概念就比较迷了,其实它不像多进程和多线程那样可以在多核机器上提供并行的能力,而是侧重于相互协作共同完成某个任务,同一个线程中可以启动多个协程,但这些协程同一时刻只能有一个在运行。
协程其实可以看成是一个可以被随时停止和唤醒的函数,使用协程是为了在用户层面来控制调用逻辑,对比于多线程程序的线程调度完全看操作系统的心情的处境,多协程的程序就比较自主了,可以由开发者来控制函数执行顺序。
还有一个特性很重要,就是使用协程可以实现用“同步”的方式来写“异步”的代码,这一点不理解没关系,以后会慢慢明白的。说到这,不得不说一下关于同步和异步、阻塞和非阻塞这几个概念,它们常常被大家混在一起来说,实际上只是从不同维度来描述了一件事情,下面简单叙述下。
同步 vs 异步
同步和异步指的是消息通信的机制,或者说得到结果的方式。
同步:调用函数后就能返回想要的结果,有点像去食堂买饭,自己去食堂付完钱(调用),饭(结果)就可以被拿回来了,这就是同步调用的方式,与返回结果的时间长短无关,得到结果之后直接执行后面的逻辑(吃饭)就可以了,所以同步的逻辑是最好写的。
异步:调用函数后并不能直接得到想要的结果,需要通过回调或者其他消息来通知,这就有点像定外卖了,打开APP选好饭菜输入地址(注册回调),开始付钱(调用),此时并不能直接得到饭(结果),而是一段时间之后,有外卖小哥将饭(结果)给你送来,这时才能执行后面的逻辑(吃饭)。
总结来说,需要自己取结果的就是同步,依靠别人送结果的就是异步。
阻塞 vs 非阻塞
阻塞和非阻塞指的是程序在等待调用结果时的状态,强调在获得结果之前的表现。
阻塞:调用函数后由于不满足某种条件(比如读socket但是没有数据)被挂起,当条件满足(socket来数据了)时被唤醒,并将结果返回。
非阻塞:调用函数后如果不满足指定条件(比如读socket但是没有数据)不挂起,而是返回一个表示没有取到结果的值,你可以按照某种间隔再次调用函数,直到取到结果为止,当然你也可以调用一次就结束了。
总结来说,不满足条件时调用方被挂起就是阻塞调用,否则就是非阻塞调用。
协程学习
C++的协程是暂时学不明白了,为了不翻车,我还是从熟悉的 Lua 入手,来举例说明什么是协程?有什么用?为什么这样用?弄明白以后再慢慢用 C++ 来实现相同的目的,毕竟 C++ 这一块需要实现的内容也有点多。
消费者-生产者
提到 Lua 的协程就会想到 “消费者-生产者”的例子,网上关于这个的实现有特别多的版本,整体上来说大同小异,基本上都是 《C++程序设计》这本书中的内容,但是这一部分我看了很多遍,感觉这个例子并不太好。
1 | function receive(prod) -- 激活协同程序 |
这就是一个消费者驱动的模型,首先由启动消费者,然后调用生产者来生产资源,接着消费者消耗掉新的资源,再控制生产者生产新的资源,以此方式循环往复,其实就是下面代码的复杂化:
1 | function consumer_producer() |
这个例子以我现在的菜鸟水平来看没啥用,但是有一点比较好,就是展示了可以用协程来控制程序执行顺序的强大功能,只是这个消费者和生产者强耦合的设计实在是看不明白。
自己想个例子
既然他们的例子我都不喜欢,那我就自己想一个,叮铃铃!下面我收到了一个新的需求:
计算1+2+3+4+5+6+7+8+9+10的和,然后等待5秒钟后,将结果显示在控制台上。
乍一听,这个需求太简单了吧,没有一点难度,其实不然,其中蕴含着大量玄机,简直就是一个万能句式:
做一件事情A,然后等待某件事发生,再做一件事情B(可能与A相关)
仔细想想,这样的“句式”在开发中,生活中是不是经常出现?
- 下载电影,下载完成后,播放电影
- 开始加载场景,加载完成后,隐藏加载进度条
- 发送一个请求,收到回复时,将回复结果显示出来
- …
看了吧,现实中有很多这类需求,我们接下来尝试着实现一下
常规写法
1 | -- lua 没有 sleep 函数,使用while循环模拟 |
代码很简单,为了看起来更连贯这里就不分段展示了,首先模拟一个 sleep
函数,然后实现 task_method_1
函数来完成原始需求——求和、等待、展示,最后通过主函数来调用就可以了。
运行结果如下:
program start at 01:30:27
program end at 01:30:32 and sum = 55
进阶写法
看了上面的代码有没有发现什么问题?这是一种同步的实现方式,整个程序在中间等待的5秒钟什么都不能做,必须等倒计时结束才能做后面的事情,这要是购物APP点了5秒没反应就直接X掉了,这可是赤果果的金钱损失啊,绝不能让这种事情发生。
怎么办呢?我确实需要5秒钟的处理时间,但是又不能让用户卡在那,我可以显示一个进度条,进度一直再变化,用户就不会以为程序卡死了,如果进度走的比较慢,他可能以为手机老旧该换了,没准还促进了手机的销量呢!
顺着这个思路写出了下面这种实现,这是一种异步的实现方式,通过回调函数来通知最终要显示的结果。
1 | function task_method_2() |
在函数 task_method_2
中计算完求和的结果,并没有等待,而是通过 add_callback
函数注册了等待时间、回调函数、以及回调展示的结果,然后直接返回了调用方,调用主函数 main2
中计算这时间差并展示进度,等倒计时一结束就执行回调函数,进而展示出结果。
运行结果如下,通过打印信息展示处理进度条:
program start at 01:44:56
program run 1.000000 seconds
program run 2.001000 seconds
program run 3.001000 seconds
program run 4.001000 seconds
program run 5.001000 seconds
program end at 01:45:01 and sum = 55
协程写法
卡顿的问题解决了,但是添加了一大堆额外的注册和回调函数,有些麻烦啊,怎么把它们去掉呢?
终于等到协程出场了,同步调用很卡、异步回调很烦,那么协程可以实现用“同步”的方式来写“异步”的代码,既不卡也不烦,下面来看一下实现。
1 | function task_method_3() |
对比 task_method_3
和 task_method_1
函数,只是将 sleep
函数换成了 coroutine.yield(5)
,整个需求函数很紧凑。
程序运行逻辑是这样的,先将 task_method_3
函数包装成协程 co
,然后启动 co
执行求和逻辑,执行到 coroutine.yield(5);
这句,协程被暂停并将5返回,主函数 main3
中收到返回值5后开始计时并展示进度值,直到5秒等待期结束再次唤醒协程 co
,coroutine.yield(5);
后面的代码继续执行,完成最后的展示需求。
运行结果如下:
program start at 01:50:59
program run 1.000000 seconds
program run 2.000000 seconds
program run 3.000000 seconds
program run 4.000000 seconds
program run 5.000000 seconds
program end at 01:51:04 and sum = 55
总结
- 多进程/多线程的引入并不是总能降低任务消耗的时间,还要考虑到进程/线程切换的消耗问题,参考Redis实现
- 多协程的引入本质上是为了更好的控制程序运行的逻辑,虽然它往往也能带来效率上的提升
coroutine.yield
是协程的中核心函数,主动让出CPU,如果协程不自己挂起,外部无法干预- 知识的迁移是一项重要的技能,下一步要用C++协程来实现这个需求啦,边学边写喽
拨开那一片云,是你未曾实现的梦想,岁月流转,梦想在变,有些事不得不放弃坚守(固执),珍惜眼前的一切,迎接明天的朝阳~
2021-5-28 00:27:18