协程组件Ntyco学习笔记

目录

协程是什么?

为什么需要协程?

原因

协程的优点

协程的缺点

协程如何实现?

上下文切换

汇编实现

原语操作

yield

resume

调度器(scheduler)

hook

协程整体的流程

协程如何被调度

生产者-消费者模式

多状态运行

协程具体的使用场景举例

参考文章及项目


协程是什么?

协程就是用同步的编程思维,实现异步的操作。

为什么需要协程?

原因

在主线程上的大量I/O操作中,当前执行的I/O操作此时不可读也不可写,如果是同步操作,那我只能一直等到当前这个I/O可操作并且将它执行完成后才能走下一个I/O操作,等待的这个时间就被白白浪费了。

而使用异步操作,如果当前等待的I/O事件处于等待的状态,就直接切换到其他I/O事件,执行效率就会大大提高,因为I/O事件处理是很慢的。

但是使用异步来处理,如果I/O操作涉及的步骤较多就会出现回调地狱的情况,代码可读性很差,而且处理临界资源,避免一个I/O被多个线程操作。而且线程的切换也会有额外的开销。

协程的出现就解决了同步操作效率低下,异步操作逻辑不好理解和开销的问题。用同步的编程方式实现异步的操作。

协程的优点

用户态调度:协程由程序(或运行时库)自行调度,无需内核介入,切换开销极小(通常为微秒级)。 

非抢占式:协程只有在主动yield或await时才会让出控制权,避免线程抢占带来的竞争问题

协程的缺点

协程实际上还是一个单线程的程序,无法利用到多核资源。不过golang的协程现在已经可以充分利用多核 CPU 资源,但我不太了解这部分。

协程适合I/O密集型任务,对于一些 CPU 密集型任务(如科学计算、图像处理),仅使用协程的的话,由于无法利用多核资源,会导致整个线程阻塞,失去并发优势。

协程如何实现?

参考的项目地址在文章结尾给出。

主要围绕该项目中的core目录下的nty_coroutine.c、nty_schedule.c和nty_socket.c进行总结。

上下文切换

这部分几乎皆取自Ntyco的wiki。

既然要切换前后不同的协程,那前一个协程的内容肯定需要保存下来,例如:

寄存器状态:

  • 通用寄存器:如 RAX、RBX、RCX 等(x86_64 架构),保存临时数据和计算结果。
  • 指令指针(IP/EIP/RIP):指向下一条要执行的指令地址,恢复时从此处继续执行。
  • 栈指针(SP/ESP/RSP):指向当前栈顶位置,保存局部变量和函数调用上下文。
  • 基址指针(BP/EBP/RBP):指向栈帧底部,用于访问局部变量和参数。

栈内容:协程的局部变量、函数调用参数和返回地址都存储在栈中,切换时需要保存整个栈的状态(或差异部分)。

协程私有数据:如用户定义的上下文变量、协程状态(就绪 / 运行 / 阻塞)等。

所以上下文切换,就是将CPU的寄存器暂时保存,再将即将运行的协程的上下文寄存器,分别mov到相对应的寄存器上。此时上下文完成切换。

实现的方式有:

  • 汇编指令:直接操作 CPU 寄存器(如 x86 的 EAX、EBX)保存 / 恢复上下文。
  • C 语言技巧:使用setjmp/longjmpucontext系列函数(Linux)。
  • 现代语言:Go 的runtime包、Python 的asyncio通过生成器(Generator)实现。

Ntyco中实现方式是使用汇编或ucontext。

汇编实现

x86_64 的寄存器有16个64位寄存器,分别是:%rax, %rbx, %rcx, %esi, %edi, %rbp, %rsp, %r8, %r9, %r10, %r11, %r12, %r13, %r14, %r15。

%rax 作为函数返回值使用的。

%rsp 栈指针寄存器,指向栈顶

%rdi, %rsi, %rdx, %rcx, %r8, %r9 用作函数参数,依次对应第1参数,第2参数。。。

%rbx, %rbp, %r12, %r13, %r14, %r15 用作数据存储,遵循调用者使用规则,换句话说,就是随便用。调用子函数之前要备份它,以防它被修改 %r10, %r11 用作数据存储,就是使用前要先保存原值。

cpu上下文信息结构体:

typedef struct _nty_cpu_ctx {
	void *esp; //
	void *ebp;
	void *eip;
	void *edi;
	void *esi;
	void *ebx;
	void *r1;
	void *r2;
	void *r3;
	void *r4;
	void *r5;
} nty_cpu_ctx;

这里可以对照本小节开头寄存器状态介绍对照。例如 eip 用来存储CPU运行下一条指令的地址。我们可以把回调函数的地址存储到EIP中,将相应的参数存储到相应的参数寄存器中。实现子过程调用的逻辑代码如下:

void _exec(nty_coroutine *co) {
    co->func(co->arg); //子过程的回调函数
}

void nty_coroutine_init(nty_coroutine *co) {
    //ctx 就是协程的上下文
    co->ctx.edi = (void*)co; //设置参数
    co->ctx.eip = (void*)_exec; //设置回调函数入口
    //当实现上下文切换的时候,就会执行入口函数_exec , _exec 调用子过程func
}

切换上下文函数定义:

// 新协程:new_ctx 当前协程:cur_ctx
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);

实现代码:

#ifdef __i386__
__asm__ (
"    .text                                  \n"
"    .p2align 2,,3                          \n"
".globl _switch                             \n"
"_switch:                                   \n"
"__switch:                                  \n"
"movl 8(%esp), %edx      # fs->%edx         \n"
"movl %esp, 0(%edx)      # save esp         \n"
"movl %ebp, 4(%edx)      # save ebp         \n"
"movl (%esp), %eax       # save eip         \n"
"movl %eax, 8(%edx)                         \n"
"movl %ebx, 12(%edx)     # save ebx,esi,edi \n"
"movl %esi, 16(%edx)                        \n"
"movl %edi, 20(%edx)                        \n"
"movl 4(%esp), %edx      # ts->%edx         \n"
"movl 20(%edx), %edi     # restore ebx,esi,edi      \n"
"movl 16(%edx), %esi                                \n"
"movl 12(%edx), %ebx                                \n"
"movl 0(%edx), %esp      # restore esp              \n"
"movl 4(%edx), %ebp      # restore ebp              \n"
"movl 8(%edx), %eax      # restore eip              \n"
"movl %eax, (%esp)                                  \n"
"ret                                                \n"
);
#elif defined(__x86_64__)

__asm__ (
"    .text                                  \n"
"       .p2align 4,,15                                   \n"
".globl _switch                                          \n"
".globl __switch                                         \n"
"_switch:                                                \n"
"__switch:                                               \n"
"       movq %rsp, 0(%rsi)      # save stack_pointer     \n"
"       movq %rbp, 8(%rsi)      # save frame_pointer     \n"
"       movq (%rsp), %rax       # save insn_pointer      \n"
"       movq %rax, 16(%rsi)                              \n"
"       movq %rbx, 24(%rsi)     # save rbx,r12-r15       \n"
"       movq %r12, 32(%rsi)                              \n"
"       movq %r13, 40(%rsi)                              \n"
"       movq %r14, 48(%rsi)                              \n"
"       movq %r15, 56(%rsi)                              \n"
"       movq 56(%rdi), %r15                              \n"
"       movq 48(%rdi), %r14                              \n"
"       movq 40(%rdi), %r13     # restore rbx,r12-r15    \n"
"       movq 32(%rdi), %r12                              \n"
"       movq 24(%rdi), %rbx                              \n"
"       movq 8(%rdi), %rbp      # restore frame_pointer  \n"
"       movq 0(%rdi), %rsp      # restore stack_pointer  \n"
"       movq 16(%rdi), %rax     # restore insn_pointer   \n"
"       movq %rax, (%rsp)                                \n"
"       ret                                              \n"
);
#endif

原语操作

需要以上上下文切换实现

yield

协程yield(让出)CPU,协程对应的I/O操作尚未就绪、协程对应操作全部完成等情况下执行;

// 暂停协程执行
void nty_coroutine_yield(nty_coroutine *co) {
	co->ops = 0;
#ifdef _USE_UCONTEXT
	if ((co->status & BIT(NTY_COROUTINE_STATUS_EXITED)) == 0) {
		// 保存协程栈
		_save_stack(co);
	}
	// 切换上下文
	swapcontext(&co->ctx, &co->sched->ctx);
#else
	// 上下文切换
	_switch(&co->sched->ctx, &co->ctx);
#endif
}

resume

resume(恢复)协程运行。在协程对应的I/O操作处理完成、睡眠时间结束或延迟时间结束等情况下执行。

// 恢复协程执行
int nty_coroutine_resume(nty_coroutine *co) {
	if (co->status & BIT(NTY_COROUTINE_STATUS_NEW)) {
		// 初始化新协程
		nty_coroutine_init(co);
	} 
#ifdef _USE_UCONTEXT	
	else {
		// 加载协程栈
		_load_stack(co);
	}
#endif
	nty_schedule *sched = nty_coroutine_get_sched();
	// 设置当前正在运行的协程
	sched->curr_thread = co;
#ifdef _USE_UCONTEXT
	// 切换上下文
	swapcontext(&sched->ctx, &co->ctx);
#else
	// 上下文切换
	_switch(&co->ctx, &co->sched->ctx);
	// 对协程栈进行内存优化
	nty_coroutine_madvise(co);
#endif
	sched->curr_thread = NULL;
#if 1
	if (co->status & BIT(NTY_COROUTINE_STATUS_EXITED)) {
		if (co->status & BIT(NTY_COROUTINE_STATUS_DETACH)) {
			// 释放已退出且分离的协程资源
			nty_coroutine_free(co);
		}
		return -1;
	} 
#endif
	return 0;
}

调度器(scheduler)

调度器用于统一管理协程的状态。

为什么一定需要调度器?

协程的状态包括就绪、等待、可读可写等多个状态,如果没有调度器,不利于维护这么多个状态,对于协程生命周期的管理会分散在不同的模块里,就会使得代码不那么好实现。

调度器通过 epoll 来获取当前需要执行的协程。当协程满足恢复执行的条件时,协程对应的fd会被 epoll 移除,而让出时会被添加进去,这样操作能够保证 fd 只在一个上下文中 能够操作I/O的。不会出现在多个上下文同时对一个 I/O 进行操作的。

调度器主要通过队列和红黑树管理协程:例如:就绪队列、存放处于等待状态协程的红黑树、存放处于睡眠状态的红黑树。

这些数据结构对应的协程状态不一定是冲突的,比如一个协程既能加入到等待状态的红黑树中等待I/O就绪,也能加入睡眠状态的红黑树中处理超时操作;延迟队列可作为辅助,与主状态结构共存,用于实现超时机制。

// 定义协程调度器结构体
typedef struct _nty_schedule {
	uint64_t birth;                   // 调度器创建时间
#ifdef _USE_UCONTEXT
	ucontext_t ctx;                   // 使用ucontext的上下文
#else
	nty_cpu_ctx ctx;                  // 自定义的CPU上下文
#endif
	void *stack;                      // 调度器栈指针
	size_t stack_size;                // 调度器栈大小
	int spawned_coroutines;           // 已创建的协程数量
	uint64_t default_timeout;         // 默认超时时间
	struct _nty_coroutine *curr_thread; // 当前正在运行的协程
	int page_size;                    // 页面大小
	int poller_fd;                    // epoll文件描述符
	int eventfd;                      // 事件文件描述符
	struct epoll_event eventlist[NTY_CO_MAX_EVENTS]; // epoll事件列表
	int nevents;                      // 事件数量
	int num_new_events;               // 新事件数量
	pthread_mutex_t defer_mutex;      // 延迟队列互斥锁
	nty_coroutine_queue ready;        // 就绪队列
	nty_coroutine_queue defer;        // 延迟队列
	nty_coroutine_link busy;          // 忙碌链表
	nty_coroutine_rbtree_sleep sleeping; // 睡眠红黑树
	nty_coroutine_rbtree_wait waiting;   // 等待红黑树
	//private 
} nty_schedule;

调度器运行代码:

// 该函数用于启动调度器的运行
void nty_schedule_run(void) {
    // 获取当前的调度器
    nty_schedule *sched = nty_coroutine_get_sched();
    if (sched == NULL) return ;

    // 循环执行,直到调度器完成所有任务
    while (!nty_schedule_isdone(sched)) {
        // 1. 处理过期的协程(睡眠红黑树)
        nty_coroutine *expired = NULL;
        while ((expired = nty_schedule_expired(sched)) != NULL) {
            // 恢复过期协程的执行
            nty_coroutine_resume(expired);
        }
        // 2. 处理就绪队列中的协程
        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. 处理等待红黑树中的协程
        nty_schedule_epoll(sched);
        while (sched->num_new_events) {
            // 从新事件数量中减 1
            int idx = --sched->num_new_events;
            // 获取当前事件
            struct epoll_event *ev = sched->eventlist+idx;
            
            // 获取事件对应的文件描述符
            int fd = ev->data.fd;
            // 检查事件是否为文件描述符结束事件
            int is_eof = ev->events & EPOLLHUP;
            if (is_eof) errno = ECONNRESET;

            // 在等待红黑树中查找该文件描述符对应的协程
            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 ;
}

hook

Hook操作核心就是用自定义函数替换系统函数,保证所有代码在不更改流程的情况下使用协程。使调用系统函数时实际执行的是我们编写的代码。

在协程中,hook操作就是把系统调用中的阻塞 IO 函数进行替换,使其在等待 IO 结果时主动让出控制权。

在nty_socket.c中例如send()和nty_send()

ssize_t nty_send(int fd, const void *buf, size_t len, int flags) {
	
	int sent = 0;

	int ret = send(fd, ((char*)buf)+sent, len-sent, flags);
	if (ret == 0) return ret;
	if (ret > 0) sent += ret;

	while (sent < len) {
		struct pollfd fds;
		fds.fd = fd;
		fds.events = POLLOUT | POLLERR | POLLHUP;

		nty_poll_inner(&fds, 1, 1);
		ret = send(fd, ((char*)buf)+sent, len-sent, flags);
		//printf("send --> len : %d\n", ret);
		if (ret <= 0) {			
			break;
		}
		sent += ret;
	}

	if (ret <= 0 && sent == 0) return ret;
	
	return sent;
}

ssize_t send(int fd, const void *buf, size_t len, int flags) {

	if (!send_f) init_hook();

	nty_schedule *sched = nty_coroutine_get_sched();
	if (sched == NULL) {
		return send_f(fd, buf, len, flags);
	}

	int sent = 0;

	int ret = send_f(fd, ((char*)buf)+sent, len-sent, flags);
	if (ret == 0) return ret;
	if (ret > 0) sent += ret;

	while (sent < len) {
		struct pollfd fds;
		fds.fd = fd;
		fds.events = POLLOUT | POLLERR | POLLHUP;

		nty_poll_inner(&fds, 1, 1);
		ret = send_f(fd, ((char*)buf)+sent, len-sent, flags);
		//printf("send --> len : %d\n", ret);
		if (ret <= 0) {			
			break;
		}
		sent += ret;
	}

	if (ret <= 0 && sent == 0) return ret;
	
	return sent;
}

nty_poll_inner() 用于将 fd 加入到 epoll 中监听,并让出当前协程,待文件描述符就绪后再从 epoll 中移除该 fd。代码就不再列出了。

而上面给出的send(),nty_send()函数,均实现了异步操作,这两个函数实现的主要功能没有区别。nty_send() 中调用的 send() 就是上面的 send() 。

协程整体的流程

这部分皆取自Ntyco的wiki。

在hook操作的基础上,通过调度器来实现对协程的管理。

在协程的上下文IO异步操作(nty_recv,nty_send)函数,步骤如下:

1. 将 sockfd 添加到epoll管理中。

2. 进行上下文环境切换,由协程上下文yield到调度器的上下文。

3. 调度器获取下一个协程上下文。Resume新的协程。

IO 异步操作的上下文切换的时序图如下:

创建协程及调度器 --> 运行调度器 --> 调度器分配协程 --> 协程运行时阻塞让出控制权给调度器-->调度器分配协程执行...

协程如何被调度

这部分皆取自Ntyco的wiki。

生产者-消费者模式

满足运行条件的协程都需要交给就绪队列,由就绪队列将协程resume。

实现代码:

while (1) { 
    //遍历睡眠集合,将满足条件的加入到ready 
    nty_coroutine *expired = NULL; 
    while ((expired = sleep_tree_expired(sched)) != ) { 
        TAILQ_ADD(&sched->ready, expired); 
    } 
    //遍历等待集合,将满足添加的加入到ready 
    nty_coroutine *wait = NULL; 
    int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1); 
    for (i = 0;i < nready;i ++) { 
        wait = wait_tree_search(events[i].data.fd); 
        TAILQ_ADD(&sched->ready, wait); 
    } 
    // 使用resume回复ready的协程运行权 
    while (!TAILQ_EMPTY(&sched->ready)) { 
        nty_coroutine *ready = TAILQ_POP(sched->ready); 
        resume(ready); 
    } 
} 

多状态运行

每个数据结构中的协程只要满足条件都可以直接 resume 协程。

实现代码: 

while (1) { 

    //遍历睡眠集合,使用resume恢复expired的协程运行权 
    nty_coroutine *expired = NULL; 
    while ((expired = sleep_tree_expired(sched)) != ) { 
        resume(expired); 
    }
 
    //遍历等待集合,使用resume恢复wait的协程运行权 
    nty_coroutine *wait = NULL; 
    int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1); 
    for (i = 0;i < nready;i ++) { 
        wait = wait_tree_search(events[i].data.fd); 
        resume(wait); 
    }
 
    // 使用resume恢复ready的协程运行权 
    while (!TAILQ_EMPTY(sched->ready)) { 
        nty_coroutine *ready = TAILQ_POP(sched->ready); 
        resume(ready); 
    }
}

协程具体的使用场景举例

高并发网络服务

比如图床服务器处理客户端上传、下载图片请求,rpc服务器解析客户端请求、组织回复内容这类I/O密集型的高并发网络服务。

游戏开发

角色技能的冷却时间可以使用协程计时,加入睡眠状态红黑树,无需轮询检查冷却时间。多人游戏中,发送 / 接收数据时继续游戏运行。资源加载与初始化,使用协程异步加载资源(如模型、纹理、音频)。

其实这就是一个组件,只要合适就可以用,没有那么绝对的非他不可的场景。

参考文章及项目

https://github.com/0voice

NtyCo的实现

纯c协程框架NtyCo实现与原理

协程能够给我们带来什么?为什么用协程?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值