Linux C/C++ 学习日记(32):协程(二):Ntyco源码解析

注:该文用于个人学习记录和知识交流,如有不足,欢迎指点。

Ntyco源码:

https://github.com/wangbojing/NtyCo

一、实现的思路:

1. scheduler负责给协程分配CPU

2. 上下文切换如何实现

本质:

  • resume:调度器从CPU中取走其当前的执行状态然后保存下来,接着把协程保存的之前的执行状态再移动到CPU当中(代表开始执行协程)。
  • yield:协程从CPU中取走其当前的执行状态然后保存下来,接着把调度器保存的之前的执行状态再移动到CPU当中(代表开始执行调度器)。

2.1 汇编

2.2 ucontext(见上篇博文)

二、 代码结构

1. 核心数据结构

(1)协程状态(nty_coroutine_status

定义了协程的生命周期状态,包括:

  • 初始化相关:NTY_COROUTINE_STATUS_NEW(新建)、NTY_COROUTINE_STATUS_READY(就绪)
  • 运行相关:NTY_COROUTINE_STATUS_BUSY(运行中)、NTY_COROUTINE_STATUS_EXITED(已退出)
  • I/O 等待相关:NTY_COROUTINE_STATUS_WAIT_READ(等待读)、NTY_COROUTINE_STATUS_WAIT_WRITE(等待写)
  • 其他状态:SLEEPING(睡眠)、CANCELLED(取消)、DETACH(分离)等。
(2)调度器(nty_schedule

每个线程有一个调度器(通过pthread_key_t实现线程局部存储),负责协程的管理和调度,核心成员包括:

  • 上下文信息(ctx):使用ucontext_t(系统提供)或自定义nty_cpu_ctx(汇编实现)保存调度器自身的上下文。
  • 队列 / 链表:ready(就绪协程队列)、defer(延迟执行队列)、busy(运行中协程链表)。
  • 红黑树:sleeping(睡眠协程,按唤醒时间排序)、waiting(I/O 等待协程,按 FD 和事件排序)。
  • I/O 多路复用:poller_fd(epoll 实例 FD)、eventlist(epoll 事件列表),用于监听 I/O 事件。
(3)协程(nty_coroutine

单个协程的核心信息,包括:

  • 上下文(ctx):保存协程的执行状态(寄存器、栈指针等)。
  • 执行信息:func(协程函数)、arg(参数)、stack(栈空间)、stack_size(栈大小)。
  • 状态与调度:status(当前状态)、sched(所属调度器)、各类队列节点(用于加入就绪 / 等待队列)。
  • I/O 信息:fd(关联的文件描述符)、events(等待的事件,如POLLIN)、io(I/O 操作的缓存和结果)。

2. 核心机制

(1)上下文切换

协程的切换是核心,代码提供了两种实现:

  • 基于ucontext :使用系统调用getcontext/swapcontext保存和切换上下文(简单但可能效率较低)。
  • 自定义汇编 :通过汇编直接操作寄存器(esp/ebp/eip等),实现轻量切换(_switch函数),支持 x86 和 x86_64 架构。

切换逻辑:

  • nty_coroutine_yield:协程主动让出 CPU,保存自身上下文,切换到调度器上下文。
  • nty_coroutine_resume:调度器唤醒协程,恢复协程上下文,切换到协程执行。

(2)I/O 多路复用与协程调度

通过epoll监听 I/O 事件,结合协程实现 "非阻塞 I/O + 主动让出 CPU" 的高效模式:

  1. 当协程执行 I/O 操作(如nty_recv/nty_accept)时,若 I/O 未就绪(如无数据可读),协程会:
    • 将自身注册到epoll的等待队列(通过nty_schedule_sched_wait)。
    • 然后插入wait树
    • 调用yield让出 CPU,进入WAIT_READ/WAIT_WRITE状态。
  2. 调度器通过epoll_wait等待 I/O 事件,当事件触发(如数据到达):
    • 调度器从wait树中根据fd找到对应协程,通过resume恢复执行。
    • 协程得到执行后,删除注册的epoll事件,移除其所在的wait树,然后执行普通的io操作(recv/accept)

(3)系统调用钩子(COROUTINE_HOOK

通过dlsym重写系统调用(如socket/read/send),实现对用户透明的协程化:

  • 钩子函数会先检查当前是否在协程环境中(通过nty_coroutine_get_sched)。
  • 若在协程中,自动将阻塞式 I/O 转为协程式的io。
  • 若不在协程中,直接调用原始系统调用,保证兼容性。

3. 主要功能函数

  • 协程管理nty_coroutine_create(创建协程)、nty_coroutine_free(释放协程)、nty_coroutine_sleep(协程睡眠)。
  • I/O 操作nty_socket(创建非阻塞 socket)、nty_accept/nty_recv/nty_send(协程化的 I/O 函数)。
  • 调度器运行nty_schedule_run(调度主循环,遍历三种状态结构、选择协程执行)。

4. 适用场景

该协程库适用于高并发网络编程(如 HTTP 服务器、TCP 代理),通过以下特性提升性能:

  • 避免线程切换的开销(协程切换在用户态,成本远低于线程)。
  • 高效处理大量 I/O 等待(通过 epoll 复用,协程在 I/O 就绪前不占用 CPU)。
  • 透明兼容现有系统调用(钩子机制无需修改原有代码逻辑)。

总结

这个协程库通过 "用户态上下文切换 + epoll 多路复用 + 系统调用钩子" 的组合,实现了轻量、高效的并发模型,适合 I/O 密集型场景。核心优势是低开销的协程切换和对阻塞 I/O 的自动适配,让开发者可以用同步代码的逻辑编写异步高性能程序。

三、调度器(nty_schedule)的执行策略

调度器是协程的 "管理者",核心职责是维护协程状态、触发上下文切换、处理 IO 与睡眠等待,执行策略围绕 "高效管理 + 按需调度" 设计。

1. 协程存储与状态管理

  • 用不同数据结构分类管理协程,保证操作高效:
    • 就绪协程:TAILQ队列(FIFO,快速入队 / 出队),存储等待执行的协程。
    • 睡眠协程:RB树(按睡眠时间排序),快速查找 最近到期" 的协程。
    • IO 等待协程:RB树(按文件描述符 fd 排序),快速通过 fd 查找并唤醒等待 IO 的协程。
    • 忙碌协程:LIST链表,存储正在执行的协程。

2. 核心调度循环(nty_schedule_run)

调度器通过无限循环实现持续调度,每次循环分 3 步执行:

  1. 处理过期睡眠协程:遍历睡眠红黑树,取出 "睡眠时间已到" 的协程,通过nty_coroutine_resume切换上下文,让协程执行;协程执行中若挂起(yield),会切回调度器上下文,继续下面的调度
  2. 执行就绪队列协程:从就绪队列取出协程,通过nty_coroutine_resume切换上下文,让协程执行;协程执行中若挂起(yield),会切回调度器上下文,继续下面的调度。
  3. 处理 IO 事件:通过epoll_wait等待 IO 就绪(超时时间由 "最近到期的睡眠协程" 决定),根据fd在等待红黑树中取出对应的协程,通过nty_coroutine_resume切换上下文,让协程执行;协程执行中若挂起(yield),会切回调度器上下文,继续下面的调度。

调度模式有两种(Ntyco采取的是多状态运行模式)

2.1 生产者消费者模式

逻辑代码:

生产者-消费者模式

1. 角色对应

  • 生产者sleep_tree(睡眠红黑树)和wait_tree(IO 等待红黑树)。
    • sleep_tree 生产 "睡眠到期" 的协程,将其从睡眠状态转为就绪状态,放入ready_queue
    • wait_tree 生产 "IO 就绪" 的协程,将其从 IO 等待状态转为就绪状态,放入ready_queue
  • 缓冲区ready_queue(就绪队列)。存储所有待执行的协程,是生产者和消费者之间的 "数据中介"。
  • 消费者CPU运行(调度器的执行逻辑)。从ready_queue中取出协程,通过nty_coroutine_resume切换上下文,让协程执行。

2. 流程匹配

代码中调度器的核心循环(nty_schedule_run)完全遵循 "生产→缓冲→消费" 的逻辑:

  1. 生产者生产:遍历sleep_tree唤醒到期协程、遍历wait_tree唤醒 IO 就绪协程,将它们生产ready_queue
  2. 消费者消费:从ready_queue中取出协程,让 CPU 执行(nty_coroutine_resume)。

这种 "多生产者(sleep_tree、wait_tree)→ 共享缓冲区(ready_queue)→ 消费者(CPU 运行)" 的结构,是典型的生产者 - 消费者模式

2.2 多状态运行模式
cpp 复制代码
void nty_schedule_run(void) {
	// 获取当前线程的调度器
	nty_schedule *sched = nty_coroutine_get_sched();
	if (sched == NULL) return ; // 调度器为空,直接返回

	// 调度循环:直到所有协程处理完毕(nty_schedule_isdone返回true)
	while (!nty_schedule_isdone(sched)) {
		
		// 1. 处理过期睡眠协程:唤醒所有已到期的睡眠协程并执行
		nty_coroutine *expired = NULL;
		while ((expired = nty_schedule_expired(sched)) != NULL) {
			nty_coroutine_resume(expired); // 恢复协程执行
		}

		// 2. 处理就绪队列协程:按FIFO顺序执行就绪协程
		nty_coroutine *last_co_ready = TAILQ_LAST(&sched->ready, _nty_coroutine_queue);
		while (!TAILQ_EMPTY(&sched->ready)) {
			nty_coroutine *co = TAILQ_FIRST(&sched->ready); // 取出队列头部协程
			TAILQ_REMOVE(&co->sched->ready, co, ready_next); // 从就绪队列移除

			// 若协程已连接关闭,释放资源并退出循环
			if (co->status & BIT(NTY_COROUTINE_STATUS_FDEOF)) {
				nty_coroutine_free(co);
				break;
			}

			nty_coroutine_resume(co); // 恢复协程执行
			if (co == last_co_ready) break; // 执行到队列尾部,退出循环
		}

		// 3. 处理IO事件:唤醒IO就绪的协程并执行
		nty_schedule_epoll(sched); // 等待IO事件,获取新事件数
		while (sched->num_new_events) {
			int idx = --sched->num_new_events; // 从最后一个事件开始处理
			struct epoll_event *ev = sched->eventlist+idx; // 当前IO事件
			
			int fd = ev->data.fd; // IO事件对应的fd
			int is_eof = ev->events & EPOLLHUP; // 是否为连接关闭事件
			if (is_eof) errno = ECONNRESET; // 连接关闭,设置错误码为连接重置

			// 查找该fd对应的IO等待协程
			nty_coroutine *co = nty_schedule_search_wait(fd);
			if (co != NULL) {
				if (is_eof) {
					co->status |= BIT(NTY_COROUTINE_STATUS_FDEOF); // 标记为连接关闭
				}
				nty_coroutine_resume(co); // 恢复协程执行
			}

			is_eof = 0; // 重置连接关闭标记
		}
	}

	nty_schedule_free(sched); // 所有协程处理完毕,释放调度器资源
	
	return ;
}

多状态运行模式:

简单点说,就是依次遍历sleep、ready、wait三个结构体,若有满足需求的,就从结构体里面取走,然后resume调度。

  1. 处理过期睡眠协程:遍历睡眠红黑树,取出 "睡眠时间已到" 的协程,通过nty_coroutine_resume切换上下文,让协程执行;协程执行中若挂起(yield),会切回调度器上下文,继续下面的调度
  2. 执行就绪队列协程:从就绪队列取出协程,通过nty_coroutine_resume切换上下文,让协程执行;协程执行中若挂起(yield),会切回调度器上下文,继续下面的调度。
  3. 处理 IO 事件:通过epoll_wait等待 IO 就绪(超时时间由 "最近到期的睡眠协程" 决定),根据fd在等待红黑树中取出对应的协程,通过nty_coroutine_resume切换上下文,让协程执行;协程执行中若挂起(yield),会切回调度器上下文,继续下面的调度。

3. 调度触发条件

  • 协程主动挂起:协程执行 IO 操作(如 recv)、调用sleepyield,会主动放弃 CPU,切回调度器。
  • 事件唤醒触发:三个结构体里面有需要执行的协程就执行。
  • 协程退出触发:协程执行完函数后标记EXITED,yield之后,调度器会判断它的状态,看是否需要释放。

简单点讲:就是当前CPU运行在调度器,然后调度器遍历到有需要执行的协程就resume调度

四、协程(nty_coroutine)的执行流程

协程的生命周期围绕 "状态转换" 展开,从创建到退出全程由调度器管理,核心流程分 5 个阶段:

1. 创建与初始化

  • 调用nty_coroutine_create:分配协程内存和栈空间,初始化状态为NEW,绑定执行函数func和参数arg,加入调度器的就绪队列。

新加入的协程都是在就绪队列里面被取走的

  • 首次执行前初始化:调度器判断协程当前状态是否为NEW,若是通过nty_coroutine_init设置协程上下文(绑定栈、执行入口_exec),状态改为READY

2. 调度与执行

  • 调度器取出协程,调用nty_coroutine_resume切换上下文,协程开始执行func
  • 执行中若未触发挂起,会一直运行到函数结束,标记状态为EXITED+DETACH,然后调用yield切回调度器。

3. 挂起场景与状转换

协程执行中会因 3 种情况挂起,状态切换为对应类型:

  • IO 等待:执行 Hook 后的 IO 函数(如nty_recv),向 epoll 注册事件后,状态改为WAIT_READ/WAIT_WRITE,加入 IO 等待红黑树,调用yield挂起。
  • 睡眠:调用nty_coroutine_sleep,状态改为SLEEPING,加入睡眠红黑树,调用yield挂起。

协程挂起前(调用yield前),至少会插入3个结构体当中的一个

4. 唤醒与恢复

  • 睡眠过期:调度器遍历睡眠红黑树,找到 "睡眠时间已到" 的协程,然后resume调度
  • 新插入,位于ready队列,调度器遍历到,先给它nty_coroutine_init,然后resume调度。
  • IO 就绪:epoll 检测到 fd 可读 / 可写,调度器从 IO 红黑树找到对应协程,然后resume调度

协程唤醒后(被yeild分配到CPU),会先移除其在所有结构体中的节点(比如它是在wait树中唤醒的,那它会移除其在wait树中的节点,如果该协程也在sleep树中,它也会选择移除其在sleep树中的节点.....。其他情况同理)。
恢复执行:调度器下次循环时,通过resume切换上下文,协程从挂起点继续执行。

5. 退出与释放

  • 协程执行完func后,标记EXITED+FDEOF+DETACH,调用yield切回调度器。
  • 调度器在resume返回(即协程调用yield返回调度器当前的执行状态)后,检测到EXITED状态,调用nty_coroutine_free释放协程内存和栈空间。

五、hook的设计

代码提供hook选项

COROUTINE_HOOK开启的话:(默认开启)

在协程函数中accept之类的API自动转换为协程式的API。

  • 这样子能快速应用于原有项目,提升一大截性能
  • 同时一些封装的库(如mysql)内部的send和recv通过hook都可以直接切换成协程式的
    本质:通过dlsym重写系统调用(如socket/read/send),实现对用户透明的协程化:

dlsym: 取出系统级别对应的函数如read,赋值给read_f。

实现将nty_read改成函数名read,只需把nty_read原来里面的read改为read_f即可。

钩子函数会先检查当前是否在协程环境中(通过nty_coroutine_get_sched)。

  • 若在协程中,自动将阻塞式 I/O 转为协程式的io。
  • 若不在协程中,直接调用原始系统调用,保证兼容性。
cpp 复制代码
#ifdef  COROUTINE_HOOK

socket_t socket_f = NULL;

read_t read_f = NULL;
recv_t recv_f = NULL;
recvfrom_t recvfrom_f = NULL;

write_t write_f = NULL;
send_t send_f = NULL;
sendto_t sendto_f = NULL;

accept_t accept_f = NULL;
close_t close_f = NULL;
connect_t connect_f = NULL;

int init_hook(void) {

	socket_f = (socket_t)dlsym(RTLD_NEXT, "socket");
	
	read_f = (read_t)dlsym(RTLD_NEXT, "read");
	recv_f = (recv_t)dlsym(RTLD_NEXT, "recv");
	recvfrom_f = (recvfrom_t)dlsym(RTLD_NEXT, "recvfrom");

	write_f = (write_t)dlsym(RTLD_NEXT, "write");
	send_f = (send_t)dlsym(RTLD_NEXT, "send");
    sendto_f = (sendto_t)dlsym(RTLD_NEXT, "sendto");

	accept_f = (accept_t)dlsym(RTLD_NEXT, "accept");
	close_f = (close_t)dlsym(RTLD_NEXT, "close");
	connect_f = (connect_t)dlsym(RTLD_NEXT, "connect");

}
cpp 复制代码
int accept(int fd, struct sockaddr *addr, socklen_t *len) {

	if (!accept_f) init_hook();

	nty_schedule *sched = nty_coroutine_get_sched();
	if (sched == NULL) {
		return accept_f(fd, addr, len);
	}

	int sockfd = -1;
	int timeout = 1;
	nty_coroutine *co = nty_coroutine_get_sched()->curr_thread;
	
	while (1) {
		struct pollfd fds;
		fds.fd = fd;
		fds.events = POLLIN | POLLERR | POLLHUP;
		nty_poll_inner(&fds, 1, timeout);

		sockfd = accept_f(fd, addr, len);
		if (sockfd < 0) {
			if (errno == EAGAIN) {
				continue;
			} else if (errno == ECONNABORTED) {
				printf("accept : ECONNABORTED\n");
				
			} else if (errno == EMFILE || errno == ENFILE) {
				printf("accept : EMFILE || ENFILE\n");
			}
			return -1;
		} else {
			break;
		}
	}

	int ret = fcntl(sockfd, F_SETFL, O_NONBLOCK);
	if (ret == -1) {
		close(sockfd);
		return -1;
	}
	int reuse = 1;
	setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(reuse));
	
	return sockfd;
}

上面的accept实现的功能跟nty_accept()相同

cpp 复制代码
int nty_accept(int fd, struct sockaddr *addr, socklen_t *len) {
	int sockfd = -1;
	int timeout = 1;
	nty_coroutine *co = nty_coroutine_get_sched()->curr_thread;
	
	while (1) {
		struct pollfd fds;
		fds.fd = fd;
		fds.events = POLLIN | POLLERR | POLLHUP;
		nty_poll_inner(&fds, 1, timeout);

		sockfd = accept(fd, addr, len);
		if (sockfd < 0) {
			if (errno == EAGAIN) {
				continue;
			} else if (errno == ECONNABORTED) {
				printf("accept : ECONNABORTED\n");
				
			} else if (errno == EMFILE || errno == ENFILE) {
				printf("accept : EMFILE || ENFILE\n");
			}
			return -1;
		} else {
			break;
		}
	}

	int ret = fcntl(sockfd, F_SETFL, O_NONBLOCK);
	if (ret == -1) {
		close(sockfd);
		return -1;
	}
	int reuse = 1;
	setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(reuse));
	
	return sockfd;
}
相关推荐
Voyager_43 小时前
算法学习记录08——并归的应用(LeetCode[315])
学习·算法·leetcode
deng-c-f4 小时前
Linux C/C++ 学习日记(35):协程(五):同步、多线程、多协程在IO密集型场景中的性能测试
学习·线程·协程·同步·性能
Webb Yu4 小时前
加密货币学习路径
学习·区块链
Han.miracle5 小时前
数据库圣经-----最终章JDBC
java·数据库·学习·maven·database
Broken Arrows5 小时前
解决同一个宿主机的两个容器无法端口互通报错“No route to host“的问题记录
运维·学习·docker
JJJJ_iii5 小时前
【机器学习08】模型评估与选择、偏差与方差、学习曲线
人工智能·笔记·python·深度学习·学习·机器学习
三次拒绝王俊凯5 小时前
在实现“查询课程列表信息”功能时 出现的问题
学习
CosimaLi6 小时前
CMake学习笔记
笔记·学习
正经教主6 小时前
【Trae+AI】和Trae学习搭建App_02:后端API开发
学习·app·1024程序员节