C++对我来说简直就是星辰大海,为了避免翻船,我选择从小河沟出发

你学的越多,不懂的东西反而越多~

前言

以前觉得 C++ 并没有什么复杂的,不就是 C 语言加上类定义、模板、容器、算法函数这些就可以了吗,只要我不用,它就难不倒我,用到了查查文档也就搞定了,真的是年少轻狂啊。

随着学习的深入渐渐发现,即使抛开那些算法函数、那些冗长的模板,单单是 C++ 核心的概念和类型就够喝上好几壶的,随便罗列几个,像 std::furnitruestd::memory_orderstd::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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function receive(prod)  -- 激活协同程序
local status,value = coroutine.resume(prod)
return value
end

function send(x) -- 挂起协同程序
coroutine.yield(x)
end

function producer() -- 生产者
return coroutine.create( -- 创建协同程序
function()
while true do
local x = io.read() -- 产生新值
send(x)
end
end
)
end

function filter(prod) -- 过滤器
return coroutine.create( -- 创建协同程序
function()
for line = 1, math.huge do
local x = receive(prod) -- 激活协同程序来获取新值
x = string.format("%5d %s",line , x ) -- 过滤规则
send(x) -- 挂起激活程序
end
end
)
end

function consumer(prod)
while true do
local x = receive(prod) -- 获取新值
io.write(x, "\n") -- 消费新值
end
end

p= producer() -- 初始化生产者
f = filter(p) -- 初始化过滤器
consumer(f) -- 初始化消费者并启动程序

这就是一个消费者驱动的模型,首先由启动消费者,然后调用生产者来生产资源,接着消费者消耗掉新的资源,再控制生产者生产新的资源,以此方式循环往复,其实就是下面代码的复杂化:

1
2
3
4
5
6
7
8
function consumer_producer()
while true do
local x = io.read() -- 产生新值
io.write(x, "\n") -- 消费新值
end
end

consumer_producer() -- 启动生产者消费者

这个例子以我现在的菜鸟水平来看没啥用,但是有一点比较好,就是展示了可以用协程来控制程序执行顺序的强大功能,只是这个消费者和生产者强耦合的设计实在是看不明白。

自己想个例子

既然他们的例子我都不喜欢,那我就自己想一个,叮铃铃!下面我收到了一个新的需求:

计算1+2+3+4+5+6+7+8+9+10的和,然后等待5秒钟后,将结果显示在控制台上。

乍一听,这个需求太简单了吧,没有一点难度,其实不然,其中蕴含着大量玄机,简直就是一个万能句式:

做一件事情A,然后等待某件事发生,再做一件事情B(可能与A相关)

仔细想想,这样的“句式”在开发中,生活中是不是经常出现?

  1. 下载电影,下载完成后,播放电影
  2. 开始加载场景,加载完成后,隐藏加载进度条
  3. 发送一个请求,收到回复时,将回复结果显示出来

看了吧,现实中有很多这类需求,我们接下来尝试着实现一下

常规写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- lua 没有 sleep 函数,使用while循环模拟
function sleep(n)
local t = os.clock()
while os.clock() - t <= n do end
end

function task_method_1()
print(string.format("program start at %s", os.date("%H:%M:%S")))

-- 求和
local sum = 0;
for i=1,10 do
sum = sum + i;
end

-- 等待
sleep(5);

-- 展示
print(string.format("program end at %s and sum = %d", os.date("%H:%M:%S"), sum))
end

function main1()
task_method_1()
end

main1()

代码很简单,为了看起来更连贯这里就不分段展示了,首先模拟一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function task_method_2()
print(string.format("program start at %s", os.date("%H:%M:%S")))

-- 求和
local sum = 0;
for i=1,10 do
sum = sum + i;
end

-- 注册回调函数,进行等待
add_callback(5, call_back_print, sum)
end

function call_back_print(data)
--展示结果
print(string.format("program end at %s and sum = %d", os.date("%H:%M:%S"), data))
end

function add_callback(inteval, func, data)
interval_time = inteval
call_back = func
msg_data = data
end

function main2()
local t0 = os.clock();
local t = t0;

task_method_2()

while true do
local now = os.clock()
if now - t >= 1 then
print(string.format("program run %f seconds", now - t0))
t = now;

if interval_time and call_back and now - t0 >= interval_time then
call_back(msg_data)
break;
end
end
end
end

main2()

在函数 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function task_method_3()
print(string.format("program start at %s", os.date("%H:%M:%S")))

-- 求和
local sum = 0;
for i=1,10 do
sum = sum + i;
end

-- 等待
coroutine.yield(5);

-- 展示
print(string.format("program end at %s and sum = %d", os.date("%H:%M:%S"), sum))
end


function main3()
local t0 = os.clock();
local t = t0;

local co = coroutine.create(task_method_3)
local status, interval = coroutine.resume(co)

while true do
local now = os.clock()
if now - t >= 1 then
print(string.format("program run %f seconds", now - t0))
t = now;

if now - t0 >= interval then
coroutine.resume(co)
break;
end
end
end
end

main3()

对比 task_method_3task_method_1 函数,只是将 sleep 函数换成了 coroutine.yield(5),整个需求函数很紧凑。

程序运行逻辑是这样的,先将 task_method_3 函数包装成协程 co,然后启动 co 执行求和逻辑,执行到 coroutine.yield(5); 这句,协程被暂停并将5返回,主函数 main3 中收到返回值5后开始计时并展示进度值,直到5秒等待期结束再次唤醒协程 cocoroutine.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

Albert Shi wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客