自己动手探究一下线程的实现

我理解的线程其实就是一个个的任务,线程的概念应该是相对于操作系统来说的,对于cpu来说,cpu在一个时刻只能执行一个特定的任务(一个cpu只能串行的执行指令),cpu根本不知道什么是线程,因为有了操作系统,对这些任务进行了调度,所以才有了线程这个说法。那为什么要对任务进行调度呢?在下文中就会有答案。在前一篇文章中,阅读了Thread类的源码,当时还遗留一些问题,比如线程是如何进行调度的?线程是如何进行切换的?线程的睡眠,阻塞,唤醒等等究竟是如何做到的?在回答这些问题之前我们先来思考个问题,线程切换是个什么样的功能?顾名思义,假如有两个线程A,B,此时线程A在正常执行,此时来了一个I/O读写任务,需要等待很长的时间才能返回,如果此时线程A一直占有着cpu资源而不释放,一直等待I/O任务返回,这样的话是不是大大的浪费了cpu,在线程A等待的时间里,完全可以让出cpu,让线程B执行!这里"让出"这个动作我理解就是一次线程切换,我们来考虑下让出这个动作应该包含哪些操作。

1,由于线程A还没有运行结束,所以等到了一定时间还是要继续执行下去的,所以在"让出"前应该保存各种运行时信息,等再次运行时需要恢复这个运行时环境

2,线程A如何才能跳到线程B执行的代码上呢?这让我想起了关键字goto(java、c都有),它可以无条件跳转到指定的label上开始执行另一段代码。但goto毕竟是高级语言提供的关键字,无法直接操控cpu,在cpu指令中,jmp等指令就能帮我们实现这个功能。

围绕着上面的例子与思考,接下来我们尝试着编写代码来实现(模拟)线程的切换,在编写代码前,有些基础的概念我们需要知道。因为需要进行底层操作(要实现线程的切换),需要利用c结合汇编的方式进行,在c层面上进行一些逻辑控制,汇编层面上控制cpu进行真正的切换。既然用到汇编,那先来复习下各种寄存器,以32位cpu为例,他们分别是:

4个数据寄存器(EAX、EBX、ECX、EDX):EAX---加法乘法指令的缺省寄存器,EBX---基址寄存器,内存寻址时存放基地址,ECX---计数器,重复(REP)前缀指令和LOOP指令的内定计数器,EDX---用来放整数除法产生的余数

2个变址和指针寄存器(ESI和EDI);

2个指针寄存器(ESP和EBP):EBP---基址指针,经常用作存储函数的起始指令,ESP---栈顶指针,32位的栈是向下生长的,所以入栈操作会使得ESP减小,32位上一次减少4个字节

6个段寄存器(ES、CS、SS、DS、FS、GS)。

1个指令指针寄存器(EIP);寄存器存放下一个CPU指令存放的内存地址

1个标志寄存器(EFlags)。


有了以上的一些基础,接下来开始编写代码,首先编写我们的测试代码,然后按照测试代码中的需求一步步进行完善

/**
 * 用于线程1执行
 */
int fun1(){
	int i =3;
	while(i--){
		printf("%s\tfun1 is running at %d\n",get_time(),i);
		if(i==1){
			sleep(5);
		}
	}
	printf("%s\tfun1 exit\n",get_time());
}

/**
 * 用于线程2执行
 */
int fun2(){
	int i =3;
	while(i--){
		printf("%s\tfun2 is running at %d\n",get_time(),i);
		if(i==2){
			sleep(10);
		}
	}
	printf("%s\tfun2 exit\n",get_time());
}

/**
 * 用于线程3执行
 */
int fun3(){
	int i =3;
	while(i--){
		printf("%s\tfun3 is running at %d\n",get_time(),i);
		if(i==1){
			yield();
		}
	}
	printf("%s\tfun3 exit\n",get_time());
}

/**
 * 用于线程4执行
 */
int fun4(){
	int i =3;
	while(i--){
		printf("%s\tfun4 is running at %d\n",get_time(),i);
		if(i==2){
			yield();
		}
	}
	printf("%s\tfun4 exit\n",get_time());
}

int main(){

	int tid1,tid2,tid3,tid4;
	thread_create(&tid1,fun1);
	printf("create thread %d for exec fun1\n",tid1);
	thread_create(&tid2,fun2);
	printf("create thread %d for exec fun2\n",tid2);
	thread_create(&tid3,fun3);
	printf("create thread %d for exec fun3\n",tid3);
	thread_create(&tid4,fun4);
	printf("create thread %d for exec fun4\n",tid4);

	join(tid1);//等待线程1执行完
	join(tid2);//等待线程2执行完
	join(tid3);//等待线程3执行完
	join(tid4);//等待线程4执行完

	printf("main exited\n");

	return 0;
}


在main函数中我们定义了四个函数,分别对应四个线程执行,首先来看下main方法,进入main方法,首先要创建4线程,我们先来实现创建线程函数thread_create。上文中我们说过,所谓的线程其实就是一个个的任务,这里我们定义一个结构体my_task来描述一个线程,
typedef struct my_task{
	int id;//任务标识
	void (*fun)();//指向线程执行的函数地址
	int esp;//栈顶指针,保存当前线程的栈
	int stack[STACK_SIZE];//线程私有的栈,默认大小1024
	int status;//线程当前状态
	unsigned int wake_up_time;//唤醒时间
}my_task;
因为要实现sleep方法,所以定义了线程的状态以及唤醒时间,线程状态如下:
/*******************线程状态*********************/
#define INITIALIZED 0
#define RUNNABLE 1
#define RUNNING	2
#define	SLEEPING 3
#define EXITED 4


接着定义个数组tasks来维护这些线程,默认最大支持16个线程,接下来我们来实现create_thread函数
/**
 * 创建线程
 */
int thread_create(int *tid, void (*start)()){

	if(!tid || !start){
		printf("parameters invalid\n");
		exit(1);
	}

	my_task* task = (my_task*)malloc(sizeof(my_task));
	if(!task){
		printf("create thread failed\n");
		exit(1);
	}

	int id = 0;
	while(id < MAX_TASKS){
		if(!tasks[id]){
			break;
		}
		id++;
	}
	if(id >= MAX_TASKS){
		printf("create thread failed\n");
		exit(1);
	}

	*tid = id;

	task->id = id;
	task->fun = start;
	task->status = INITIALIZED;//初始状态
	task->wake_up_time = 0;
	int* stack = task->stack;
	task->esp = (int)(stack+STACK_SIZE-11);
	//栈向下生长
	//这里为了push操作,pop操作能够对应得上,我们保留了当前线程的寄存器状态elfags--eax,实际上在这里没有啥用处
	stack[STACK_SIZE-11] = 7; // eflags
	stack[STACK_SIZE-10] = 6; // edi
	stack[STACK_SIZE-9] = 5; // esi
	stack[STACK_SIZE-8] = 4; // edx
	stack[STACK_SIZE-7] = 3; // ecx
	stack[STACK_SIZE-6] = 2; // ebx
	stack[STACK_SIZE-5] = 1; // eax
	stack[STACK_SIZE-4] = 0; // old ebp
	stack[STACK_SIZE-3] = (int)uni_enter; //如果当前线程让出cpu后,我们是不知道该切换到哪个线程的栈上去执行,所以使用了一个统一的enter入口来决定线程的执行

	stack[STACK_SIZE-2] = 8;//这里是一个未知的返回地址(为了ret指令返回地址用)
	stack[STACK_SIZE-1] = (int)task;//enter函数的参数

	task->status = RUNNABLE;
	tasks[id] = task;

	return 0;
}



这里要看明白stack的分配的话,得明白函数调用过程中,数据是如何入栈出栈的。我们首先来看个例子

#include <stdio.h>


int fun(int a)
{
	int c = 2;
	c = a+c;
	return c;
}

int main()
{
	int a = 1;
	fun(a);
	printf("over\n");
	return 0;
}
我们用gdb对上面的代码进行debug,首先看下在进入fun函数之前,寄存器的情况:


图1

接下来看下进入fun函数后寄存器的变化:


图2

看下图1,在调用 fun函数之前,首先将eax如栈,此时eax=1,接着执行 call指令,跳到fun函数.再看下图2,进入fun之后首先将ebp入栈,注意,这里的ebp是执行main函数时的ebp哦,接着把esp赋给ebp,ok,此时ebp就相当与刚进入fun函数时的栈顶,有了这个知识,我们来看下call指令究竟干了啥:



ebp+4中的内容是0x08048409,这个值怎么这么熟悉?看图1,标黄的部分,哦,它就是main中调用fun后的下一条指令地址,它标识了fun函数返回后该执行哪一条指令,

ebp+8中的内容是1,这个不就是参数a的值吗,不就是fun函数的参数吗,不就是上面所说的“在调用 fun函数之前,首先将eax如栈,此时eax=1”,ok,这里我们就可以知道了在函数调用时,首先将函数的参数入栈,接着将下一条指令的地址入栈,然后就可以进入fun执行了!

我们再看一条指令 ret:内部操作是:栈顶字单元出栈,其值赋给IP寄存器。即实现了一个程序的转移,将栈顶字单元保存的偏移地址作为下一条指令的偏移地址,等价于pop eip.上图中的fun执行ret后,将0x08048409给eip,然后返回main接着执行。有了这些基础,再结合下文的uni_enter函数,我们就可以解释stack为什么那样分配了

stack[STACK_SIZE-3] = (int)uni_enter; //如果当前线程让出cpu后,我们是不知道该切换到哪个线程的栈上去执行,所以使用了一个统一的enter入口来决定线程的执行

	stack[STACK_SIZE-2] = 8;//这里是一个未知的返回地址(为了ret指令返回地址用)
	stack[STACK_SIZE-1] = (int)task;//enter函数的参数

stack[STACK_SIZE-3]就是我们要执行的函数,STACK_SIZE-1中存储的是函数的参数,STACK_SIZE-2存储的是函数返回后下一条指令地址,如果执行到这里,表明线程结束了。

接下来看下uni_enter函数

void uni_enter(my_task* task)
{
	task->status = RUNNING;
	task->fun();//执行线程对应的函数
	task->status = EXITED;//线程执行结束后,更改状态为退出
	my_schedule();//继续切换到另一个线程执行
}

接下来看下my_schedule函数,

void my_schedule()
{
	my_task* task = getNext();
	if(task)
	{
		context_switch(task);
	}

}
从数组中获取到下一个线程,然后切换过去,这个context_switch就是我们在汇编中实现的函数。
context_switch:
	push ebp
	mov ebp,esp

	//保存现场
	push eax
	push ebx
	push ecx
	push edx
	push esi
	push edi
	pushfd

	//切换
	mov ebx,current_task //当前线程地址放入ebx
	mov [ebx+8],esp //保存当前栈顶到current_task.esp中,ebx+8正好是esp的地址
	mov ebx,[ebp+8] //要切换到的task,ebp+4是函数返回时下一条指令地址,ebp+8正好是函数的入参
	mov current_task,ebx
	mov esp,[ebx+8] //切换栈顶

	//恢复
	popfd
	pop edi
	pop esi
	pop edx
	pop ecx
	pop ebx
	pop eax

	pop ebp

	ret

是不是看到这里的汇编,整个线程切换的脉络就清楚多了。线程切换,本质上还是esp的改变!接下来在实现下sleep和join,yield方法
void sleep(int seconds)
{
	current_task->wake_up_time = get_current_time()+seconds*1000;
	current_task->status = SLEEPING;//当前线程RUNNING--->SLEEPING
	my_schedule();
}

void yield()
{
	current_task->status = RUNNABLE;//RUNNING--->RUNNABLE
	my_schedule();
}

void join(int tid)
{
	current_task->status = RUNNABLE;
	while(tasks[tid]->status!=EXITED)//tid还未结束,继续调度
	{
		my_schedule();
	}
	free(tasks[tid]);
	tasks[tid] = NULL;
}

ok,编写好代码后,我们去试验下,结果如下图:

关注下上面标黄与标蓝处,分别是线程1和线程2的执行情况,线程1睡眠5s,线程2睡眠10s。


上面的线程切换,都是基于线程主动让出cpu,试想一下,如果编写代码的时候在线程中忘记让出cpu,比如写了一个while循环一直霸占cpu,灾难啊!所以我们有必要对线程进行调度,使得线程不至于一直霸占cpu。这里我采用时间片轮转的方式,为每个线程分配一个时间片time_slice,默认值为1(为了体现出测试效果),即每个线程的执行时间最大1ms,等时间片到了进行线程切换。这里我们很自然的想到定时器中断(由于本人不熟悉linux下的汇编,暂时也没有找到类似于windows的int 1cH定时中断入口,后续等有所了解后再来改写成汇编触发定时中断),这里我用linux提供的定时器,每1ms产生一次中断,在中断程序中,检测当前线程的时间片是否已经用完,如果已经用完,则切换当前线程,否则当前线程继续执行。其中涉及到定时器有关配置如下:

void cli() {//windows下有这个指令关中断,linux下不知道是什么
    sigset_t t;
    sigemptyset(&t);
    sigaddset(&t, SIGALRM);
    if (sigprocmask(SIG_BLOCK, &t, NULL) < 0) {
      printf("sigprocmask error\n");
      exit(1);
    }
}

void sti() {//windows下有这个指令开中断,linux下不知道是什么
    sigset_t t;
    sigemptyset(&t);
    sigaddset(&t, SIGALRM);
    if (sigprocmask(SIG_UNBLOCK, &t, NULL) < 0) {
    	 printf("sigprocmask error\n");
    	 exit(1);
    }
}

void timer_worker()
{
	if(--current_task->time_slice > 0)
	{
		return;
	}
	current_task->status = RUNNABLE;
	my_schedule();
}

void init_timer()
{
	signal(SIGALRM, timer_worker);
	struct itimerval val,old_value;
	val.it_value.tv_sec = 0;
	val.it_value.tv_usec = 1000;
	val.it_interval.tv_sec = 0;
	val.it_interval.tv_usec = 1*1000; // 10 ms
	if (setitimer(ITIMER_REAL, &val, &old_value) < 0)
	{
		printf("set timer failed\n");
		exit(1);
	}
}

在进行切换之前,关闭相关定时中断,保证线程切换正常,切换后开启定时中断。main中添加一个线程fun5,它做的事是一直循环,不主动让出cpu,最后来看下实际运行的情况,如下图:



总体来说,线程切换的本质在于栈顶esp的改变,本文还有许多待改进的工作,比如:

1,涉及到底层的操作应该都使用汇编,这样对底层的操作更为了解(这个应该比上学时学的微控制器8051,avr等要复杂太多)

2,在了解linux汇编后,可以去实现mutex,signal,cond_wait等函数的功能,这样对上层应用提供的锁(如java各种锁)原理有清晰的认知

3,想到了再来补充吧.....

ps:上面代码改在vc上运行时总是会出现莫名报错,但是在linux上运行正常,折腾了半天也没找出原因,怀疑是windows下有我不知道的操作,所以果断放弃了windows(以前学汇编都是在windows下进行)


上面所涉及到的代码均上传至:https://github.com/reverence/myThread


猜你喜欢

转载自blog.csdn.net/chengzhang1989/article/details/79712737