线程设计原理与汇编实现

一、为什么要有协程

1、协程为什么会出现

计算机的CPU执行三五个程序的时候使用的是CPU时间分片,也就是一个程序法分配一个时间片,在这个时间片没有完之前,都是属于你的运行时间,如果到了就需要交给下一个程序使用,那这时就出现一个问题,那如果程序A执行一半之后时间片用完阻塞了,此时程序B拿到CPU准备运行,那原先存储在CPU中的数据怎么办,程序A必须想办法吧这些数据记录下来,要不然等再次拿到CPU时还需要从头来。

基于这种情况就出现了进程,进程的出现是为了解决但CPU运行时程序的上下文切换问题,通过抽像出进程这样的概念,搭配虚拟内存、进程表(PCB)之类的东西,就可以用来管理独立的程序的运行和切换了。

后来,有的时候碰着I/O访问就会阻塞。此时空着也是空着,我们期望内核能把CPU切换到其他进程,让人家先用着。当然除了I\O阻塞,还有时钟阻塞等等。一开始大家都这样弄,后来发现不成,太慢了。为啥呀,一切换进程得反复进入内核,置换掉一大堆状态。进程数一高,大部分系统资源就被进程切换给吃掉了。后来搞出线程的概念,大致意思就是,这个地方阻塞了,但我还有其他地方的逻辑流可以计算,这些逻辑流是共享一个地址空间的,不用特别麻烦的切换页表、刷新TLB,只要把寄存器刷新一遍就行,能比切换进程开销少点。

如果连时钟阻塞、 线程切换这些功能我们都不需要了,自己在进程里面写一个逻辑流调度的东西。那么我们即可以利用到并发优势,又可以避免反复系统调用,还有进程切换造成的开销,分分钟给你上几千个逻辑流不费力。这就是用户态线程。

从上面可以看到,实现一个用户态线程有两个必须要处理的问题:一是碰着阻塞式I\O会导致整个进程被挂起;二是由于缺乏时钟阻塞,进程需要自己拥有调度线程的能力。如果一种实现使得每个线程需要自己通过调用某个方法,主动交出控制权。那么我们就称这种用户态线程是协作式的,即是协程。

2、协程解决了什么问题

异步请求池框架里我们提到了可以在同步状态下实现异步处理,也就是在客户端发起连接请求之后不用等待io操作,再将对应的fd添加到epoll管理时通过epoll_wait判断此时是否有io就绪,没有io就绪就准备处理下一次连接请求,如果有就进行io操作。

异步请求池最后提到了可以在兼顾异步性能的同时进行同步编程,实现方式就是使用setjmp/ longjmp函数进行跳转,但是频繁的跳转涉及到上下文的不断切换,所以我们就想能不能存在某种方式可以满足这种需求。

答案是协程,那为什么协程可以满足上述需求,原因就是协程可以兼顾异步性能的同时进行同步编程,而且协程的切换代价比线程的切换代价要低。首先协程的切换仅涉及CPU的上下文交换,就是把当前CPU内寄存器的值存储下来,然后将下一个协程的数据内容读入寄存器中。另一个原因就是协程是在用户态就可以完成切换,而线程的切换必须由内核完成。

由此可见,协程的出现就很有必要了。

二、异步的运行流程

上面说了那么就的异步,那异步的运行流程是怎样的呢:这部分内容我们通过将一个http服务器改成异步请求的方式来解释异步的运行流程。

关于http服务器前面的代码就不做过多解释了,直接来看io多路复用那块。

int read_buffer() {
	// read 
}
int write_buffer() {
	// write
}

func() {
	while(1) {
		int nready = epoll_wait();
		for(int i = 0; i < nready; i++) {
			if(events[i].event & EPOLLIN) {
				read_buffer();
			} 
			else if(events[i].evemmt & EPOLLOUT) {
				write_buffer();
			}
		}
	}
}

此时我们可以看到,原先的http服务器在读写流程时本来就不在同一个流程里面,因此这里需要进行简单修改。首先来看一下异步流程图。
在这里插入图片描述

这里在建立连接之后,我们在read之后调到epoll_wait处,然后拿到nready之后遍历去判断io是否可读,如果可读就执行读操作,然后进行写操作,此时可能由于wbuffer满从而导致write阻塞,那这时我们需要将fd挂载到epoll树上去监听写事件,然后跳转到epoll_wait处。也就是说在跳转到epoll_wait时会有两种功能情况,一种是你ready大于零,此时继续往下执行,这也是一个完整的协程操作,如果小于零,就继续执行io操作。

现在我们再来看看协程在http服务器中的实现流程:当每次遇到io操作的时候就去判断io是否就绪,如果就绪就去处理,如果没有就绪就切换到下一个协程,然后下一个协程如果遇到io操作的时也会去判断是否有io就绪,没有就继续切换。所以在这里我们能看出是否切换协程是由epoll_wait决定的。

三、协程的原语操作

1、协程在http服务器中的使用

上述介绍到了跳转到epoll_wait的时刻,这里设计到协程的两个原语操作,resume(恢复)和yeild(让出),我们首先来看一张图:
在这里插入图片描述

这张图的意思就是首先执行commit函数,再将fd通过epoll_ctl加到epoll树上时,然后就会yeild到epoll_wait这里,此时epoll_wait会进行判断,如果没有io就绪就重新发起请求,这时不需要恢复的(也就是跳转回epoll_ctl的下一步)。如果有就绪就去执行对应的io操作,处理完成之后resume恢复到原来的状态,此时图中的红线就是一个协程。其本质就是每一次io操作都会使用epoll_wait去判断fd是否就绪。

2、协程的切换

那这两个原语操作有没有共同点,答案是有的,这里涉及三种三种方法(这里介绍汇编实现):

1、longjmp / setjmp
2、ucontext
3、汇编实现

开始介绍之前,我们首先了解一下线程或进程是怎样进行切换的
在这里插入图片描述
进程或线程甚至协程的切换总体上来说就是将此时CPU内寄存器的值存储下来,然后再将下一个进程(需要切换执行的进程)内存储记录的寄存器值读入CPU中,这样就完成了切换,实际上进程、线程的切换是由很大差距的,但大体都是这么一个流程。所以我们在切换协程时也是需要这样做的,接下来看NtyCo关于协程切换的代码:

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;

#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

这里首先还需要简单介绍一下CPU中的16个寄存器(X64)

%rdi,%rsi,%rdx,%rcx,%r8,%r9
用作函数参数,依次对应第1参数,第2参数…(这里我们只需关注%rdi和%rsi)%rbx,%rbp,%r12,%r13,%14,%15
用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改new_ctx是一个指针,指向一块内存,它现在存在%rid里面,同理cur_ctx存在%rsi里面%rsp代表栈顶,%rbp代表栈底,%eip代表cpu下一条待取指令的地址(这也就是为什么resume之后会接着运行代码流程的原因)//new_ctx[%rdi]:即将运行协程的上下文寄存器列表;

cur_ctx[%rsi]:正在运行协程的上下文寄存器列表 
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);

在这里插入图片描述
这里首先要做的就是将CPU中寄存器的值记录下来,然后再将下一个协程的数据写入CPU中既可以完成了。

四、协程的定义

协程结构体:

struct coroutine {
	int id;		// 协程ID
	struct ctx {
		// 协程上下文
	}
	void *(*func)(void*);		// 回调函数
	void *arg;					// 函数参数
	int status;					// 协程状态,ready、wait、sleep
	int stack[len];				// 栈空间
	int length;					// 可用栈空间
	void *ret;					// 返回值
	queue_node (, struct coroutine) ready;
	struct queue_node {
		struct coroutine *next;
		struct coroutine *prev;
	}ready;						
	queue_node (, struct coroutine) wait;	// wait是一个树 	
	rbtree_node (, struct coroutine) sleep;// sleep是可排序的数据结构(树)
}

这里只是简单协程结构体,实际上肯定比这要复杂很多,但这些应该也能够说明接下来的问题了。现在我们来看看这三个结构队列之间的关系,首先来看一张图:
在这里插入图片描述
首先,为什么一个结构体里要有wait树、sleep树和ready队列呢?这样做的好处是什么?这是因为调度策略的选择导致的,每一次循环都会判断io时间是否超时,超时之后对应两种处理,第一种是直接处理,一种是加入到ready队列,正常来说,我们都会加入到ready队列,然后再去判断io是否等待。那这时候有设计到如何加入到ready队列,其实很简单,因为我们是先判断io是否超时,然后是否等待,最后才会判断是否就绪,因此我们ready队列一定是包含所有协程节点的,我们这时候需要做的就是将协程从对应的sleep、wait树删除即可。

五、调度器的定义

调度器结构体:

struct scheduler_op {
	remove_wait();
	remove_sleep();
	
	// 可以自定义调度策略
}

struct scheduler {
	int epfd;		
	struct epoll_event events[];
	struct coroutine *cur;					// 当前运行的协程
	
	queue_tail(, struct coroutine) ready;		// 指针
	queue_tail(, struct coroutine) wait;
	rbtree_root(, struct coroutine) sleep;

	struct scheduler_op *sch_op;				// 调度策略指针	
}

调度器首先必须有一个epfd用来管理所有的fd,然后需要一个指向ready队列的指针、一个指向wait树的指针、一个指向sleep红黑树根节点的指针。然后就是指向调度策略的指针,通过这个指针可以选择将节点从wait树上移除或者从sleep树上移除,也可以使用自定义的策略,这样我们就可以管理协程的切换了。

本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对c/c++linux系统提升感兴趣的读者,可以点击链接,详细查看详细的服务:C/C++后台服务器

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值