网络编程中的协程:从概念到实现
网络编程中的协程:从概念到实现
协程是网络编程中一种重要的并发机制,它可以在单个线程内实现高效的并发处理。本文将从同步和异步IO的基础概念开始,逐步深入到协程的定义、作用、使用场景以及具体实现方式。
一、网络IO的同步和异步是什么?
同步网络 IO:
当程序发起一个网络IO操作,程序会被阻塞,直到该操作完成。例如,在使用传统的recv函数接收网络数据时,程序会一直等待,直到有足够的数据被接收或者出现错误。在整个等待期间,程序不能做别的事情。异步网络 IO
当程序发起一个网络IO操作后,程序不会等待该操作完成,而是继续执行其他任务。当IO操作完成后,系统会通过某种方式(例如,信号、回调函数)通知程序再去对IO进行其他的操作。
二、什么是协程?为什么在网络IO要引入协程?
协程的标准定义理解起来并不直观。换种角度,直接从字面上理解:互相协作的一段程序。互相协作的方式:当程序A运行某位置被阻塞了,就直接让出CPU等资源给其他程序,当A不再被阻塞就再恢复执行原来的流程。
在网络 IO 中引入协程:
2.1. 提高程序的可读性和逻辑性
协程的一个特点就是以同步的方式编写异步操作的代码。
在没有协程时,异步网络IO往往需要使用复杂的回调函数链。当处理多个异步操作的嵌套(例如,先连接,然后多次接收数据)时,代码会变得非常复杂,回调函数层层嵌套,这就是所谓的 “回调地狱”。而协程可以将异步操作转换为看似同步的代码结构,使得代码更易于理解和维护。2.2. 更好的资源利用和性能
协程可以在单个线程内实现高效的并发。
与多线程相比,协程的上下文切换成本更低。对于网络IO,大量的连接可能会频繁地进行数据的发送和接收。协程可以在等待网络IO的过程中暂停执行,让出CPU资源给其他协程,当IO操作完成后,协程可以快速恢复执行,从而提高系统的整体性能和资源利用率。2.3. 方便错误处理
在传统的异步回调模式下,错误处理可能会分散在各个回调函数中,使得错误处理的逻辑变得复杂。而协程可以将错误处理集中在协程函数内部,使得错误更容易被捕获和处理,提高了程序的健壮性。
三、协程的一些使用场景
下面都是以客户端的角度总结的几个场景。
网页浏览器
当访问一个包含大量资源(如图片、脚本、样式表)的复杂网页时,浏览器会同时发送多个请求。例如,访问一个新闻网站,页面可能包含多个新闻图片、广告图片、JavaScript 脚本用于实现交互功能(如轮播图、评论加载)、CSS 样式表用于页面布局。浏览器会并发地发送请求获取这些资源,以尽快完整地渲染出网页。移动应用中的内容加载
以一个社交应用为例,当用户打开应用的 “发现” 页面时,应用可能会同时发送请求获取热门动态、用户推荐、广告信息等。或者在地图应用中,当用户搜索附近的餐厅时,客户端会同时发送请求获取餐厅的基本信息、用户评价、菜单等内容,以便为用户提供丰富的搜索结果展示。电商应用的商品详情页加载
在电商应用中,当用户查看一个商品详情页时,客户端可能会同时发送请求获取商品的基本信息(如名称、价格、描述)、商品图片(多张不同角度的图片)、库存信息、用户评价等。同时,还可能会请求相关推荐商品的信息,用于在详情页底部展示 “购买了该商品的用户还购买了” 等推荐内容。视频播放应用
在视频播放应用中,当用户打开一个视频时,客户端会同时发送多个请求。例如,请求视频的播放流数据,同时还会请求视频的字幕文件(如果有多种语言选项)、视频的相关推荐(用于在视频播放结束后或者侧边栏展示)、视频的元数据(如视频发布时间、点赞数、评论数等)。物联网设备管理应用
假设管理一个智能家居系统的移动应用,当用户打开应用查看家庭设备状态时,应用可能会同时向多个物联网设备(如智能灯、智能摄像头、智能插座等)发送请求获取设备状态信息,包括设备是否在线、设备的当前参数(如灯的亮度、摄像头的分辨率等)。金融交易应用
在金融交易应用中,当用户查看自己的投资组合时,客户端可能会同时发送请求获取不同金融产品(如股票、基金、债券等)的实时价格、行情走势、相关新闻等信息,以便为用户提供全面的投资组合展示。并且在进行交易操作时,如同时买入或卖出多种金融产品,也会同时发送多个交易请求。内容聚合应用
例如新闻聚合应用,当用户刷新新闻列表时,客户端会同时向多个不同的新闻源发送请求获取最新的新闻内容。或者在应用推荐聚合应用中,会同时向不同的应用商店或推荐引擎发送请求获取应用的更新信息、新应用推荐等。在线教育应用
当学生打开在线课程的章节页面时,客户端可能会同时发送请求获取课程视频、课程文档(如 PPT、教材)、课后作业、同学的讨论内容等,以便学生可以全面地开始课程学习。企业资源规划(ERP)系统客户端
在企业内部使用的 ERP 客户端中,当采购部门员工查询采购订单状态时,可能会同时发送请求获取订单的基本信息、供应商信息、货物运输状态、仓库入库准备情况等,因为这些信息来自不同的子系统,需要同时获取才能全面了解订单的情况。大数据分析客户端
当数据分析师使用大数据分析客户端工具查看一个数据集的分析结果时,客户端可能会同时发送请求获取数据的统计摘要(如均值、中位数、标准差等)、数据可视化图形(如柱状图、折线图等不同类型的图表)、数据关联分析结果等,这些不同类型的结果可能存储在不同的服务器或数据存储系统中,需要同时请求获取。
四、协程的实现过程
协程的实现过程就是一串原语操作。详细来说有四种:挂起(Suspend)、出让(Yield)、恢复(Resume)、返回(Return)。大多数情况都是Yield和Resume。
图上可以看出,有一个调度器,会把资源给某个协程去执行程序。当协程被阻塞,则会让出资源给调度器,由调度器重新分配。
在代码上有三种实现方式:setjmp/longjmp、汇编、ucontext。
- setjmp/longjmp举例:
#include <iostream>
#include <setjmp.h>
jmp_buf env;
void func1(int arg)
{
std::cout << "func1:" << arg << std::endl;
longjmp(env, ++arg);
}
void func2(int arg)
{
std::cout << "func2:" << arg << std::endl;
}
int main()
{
int ret = setjmp(env);
if (ret == 0)
{
func1(ret);
}
else
{
func2(ret);
}
return 0;
}
- ucontext相关结构体和举例:
2.1. ucontext_t:
一个用于保存用户级上下文(user - level context)的结构体。它允许程序保存当前的执行状态,包括栈信息、程序计数器、寄存器等相关内容,并且能够在之后恢复这个状态,从而实现执行流程的控制和切换。
2.2. uc_stack成员
类型为stack_t,这个成员用于存储协程或者用户级线程的栈相关信息。stack_t结构体通常包含以下几个成员:
2.2.1. ss_sp:这是栈的起始地址指针。例如,在为一个协程分配栈空间后,这个指针会指向栈内存区域的起始位置。可以是堆上分配的内存,也可以是预先定义的栈空间(如数组)。
2.2.2. ss_size:表示栈的大小。它定义了栈的字节数大小,这个大小的设置需要根据具体的应用场景和资源需求来确定。如果栈空间过小,可能会导致栈溢出;如果过大,则会浪费内存资源。
2.2.3. ss_flags:是一些栈的标志位,用于提供关于栈的额外信息,如栈是否是自动增长等特性。不过在实际使用中,这些标志位的设置相对较少,并且具体的含义和使用方式可能因操作系统和实现而有所不同。
2.3. uc_mcontext成员
这个成员用于保存机器上下文(machine context)相关的信息。它的内容是与机器架构相关的,具体来说,它保存了处理器寄存器的值等信息。因为不同的处理器架构(如 x86、ARM 等)有不同的寄存器集合和布局,所以uc_mcontext的内部结构和内容会根据具体的架构而有所不同。在进行上下文切换时,这些寄存器的值会被保存和恢复,以确保程序能够在正确的位置继续执行。
2.4. uc_link成员
类型为ucontext_t*,它是一个指向另一个ucontext_t结构体的指针。这个成员主要用于在当前上下文执行结束后,指定一个要恢复的上下文。例如,在一个协程结束后,可以通过uc_link指定的上下文来继续执行,这样可以构建协程之间的执行顺序关系。
2.5. uc_sigmask成员
用于保存信号掩码(signal mask)。信号掩码定义了在这个上下文执行期间哪些信号会被阻塞。当程序在某个特定的上下文(如协程)中执行时,可以通过设置uc_sigmask来控制信号的接收和处理,以避免信号对当前执行流程的干扰,或者将信号延迟到合适的时机处理。
举例:
#include <iostream>
#include <ucontext.h>
#include <mutex>
ucontext_t ctxs[3];
ucontext_t scheduler_ctx;
int count = 0;
std::mutex mtx;
void func0()
{
for (;;)
{
std::lock_guard<std::mutex> guard(mtx);
std::cout << "func0up:" << ++count << std::endl;
swapcontext(&ctxs[0], &scheduler_ctx);
}
}
void func1()
{
for (;;)
{
std::lock_guard<std::mutex> guard(mtx);
std::cout << "func1up:" << ++count << std::endl;
swapcontext(&ctxs[1], &scheduler_ctx);
}
}
void func2()
{
for (;;)
{
std::lock_guard<std::mutex> guard(mtx);
std::cout << "func2up:" << ++count << std::endl;
swapcontext(&ctxs[2], &scheduler_ctx);
}
}
int main()
{
char stack0[1024] = {0};
char stack1[1024] = {0};
char stack2[1024] = {0};
getcontext(&ctxs[0]);
ctxs[0].uc_stack.ss_sp = stack0;
ctxs[0].uc_stack.ss_size = sizeof(stack0);
ctxs[0].uc_link = &scheduler_ctx;
makecontext(&ctxs[0], func0, 0);
getcontext(&ctxs[1]);
ctxs[1].uc_stack.ss_sp = stack1;
ctxs[1].uc_stack.ss_size = sizeof(stack1);
ctxs[1].uc_link = &scheduler_ctx;
makecontext(&ctxs[1], func1, 0);
getcontext(&ctxs[2]);
ctxs[2].uc_stack.ss_sp = stack2;
ctxs[2].uc_stack.ss_size = sizeof(stack2);
ctxs[2].uc_link = &scheduler_ctx;
makecontext(&ctxs[2], func2, 0);
while(count < 30)
{
swapcontext(&scheduler_ctx, &ctxs[count % 3]);
}
std::cout << "done" << std::endl;
return 0;
}
设置栈空间(uc_stack)
ctxs[0].uc_stack.ss_sp = stack0;
这一步是在为ctxs[0]这个用户级上下文(ucontext_t)设置栈空间的起始地址。stack0是一个预先定义好的字符数组,它充当了ctxs[0]上下文执行时的栈空间。在程序运行过程中,函数调用、局部变量存储等操作都需要栈空间来支持,通过指定ss_sp为stack0,就为ctxs[0]相关的函数执行提供了一个独立的栈。
ctxs[0].uc_stack.ss_size = sizeof(stack0);
这是在设置栈空间的大小。它将ctxs[0]上下文的栈空间大小设置为stack0数组的大小。这个大小决定了在这个上下文执行函数时,栈能够容纳多少数据。例如,当func0函数在ctxs[0]上下文中执行时,它的函数调用栈的大小不能超过sizeof(stack0),否则可能会导致栈溢出等问题。
设置链接上下文(uc_link)
ctxs[0].uc_link = &scheduler_ctx;
这一步是在设置当ctxs[0]上下文执行的函数(在这里是func0)完成执行后,或者在func0中执行swapcontext函数将执行权转移出去后,下一个要执行的上下文。在这里,将uc_link指向scheduler_ctx,意味着当func0执行完毕或者主动放弃执行权后,执行权会转移到scheduler_ctx所代表的上下文。这就建立了一个上下文之间的链接关系,使得程序能够在不同的上下文之间进行有序的切换。例如,在func0函数中执行swapcontext(&ctxs[0], &scheduler_ctx);时,执行权会立即转移到scheduler_ctx所代表的上下文;如果func0函数正常执行完毕,由于uc_link的设置,执行权也会转移到scheduler_ctx。这种机制有助于实现多任务的协作调度,通过合理设置uc_link,可以让多个上下文按照一定的顺序或者条件进行切换,就像在这个程序中实现多个函数的轮流执行一样。
上面的代码还有一个问题,为什么scheduler_ctx没有设置对应的栈空间?
从栈空间角度考虑上下文内容看,scheduler_ctx实际上是作为一个“交换目标”上下文存在的。当其他函数上下文(ctxs[0]、ctxs[1]、ctxs[2])执行完自己的部分(例,func0、func1、func2函数执行完swapcontext语句)后,会将执行权交换回scheduler_ctx所代表的上下文。
虽然程序没有为scheduler_ctx显式地设置栈空间,但从隐式的角度看,它共享了main函数所在的栈空间。因为main函数在调用swapcontext等操作时,其栈空间就包含了程序执行过程中的局部变量(如count)以及相关的程序计数器等信息,这些信息构成了scheduler_ctx上下文的一部分。当执行权在scheduler_ctx和其他函数上下文之间切换时,栈空间的内容也会根据上下文的切换而相应地保存和恢复,以保证程序的正确执行。
从程序逻辑角度来看,它不需要像其他上下文那样初始化并设置执行函数等信息。因为它主要是作为一个 “接收” 执行权的上下文,不需要像ctxs数组中的元素那样去执行特定的用户定义函数。在swapcontext操作中,只要ctxs数组中的某个上下文和scheduler_ctx进行交换,执行权就会回到主循环(while循环)所在的上下文,此时就可以根据count的值再次选择下一个要执行的函数上下文。
- 汇编模式
对汇编的协程模式,笔者的认识就是NtyCo这个开源框架。笔者需要后面再把这个坑补起来。