背景
对进程和线程的理解,之前一直都是凭一些零碎不完整的信息在理解;
linux的进程和线程基本上一样,线程是轻量级进程,彼此有关联又独立。
得亏内核支持的好,写用户态程序可以不依赖于实现的理解,只需要知道从哪个函数开始就是线程函数了,哪里的变量是全局的,哪里的变量是线程私有的。
这样写出来的程序也不至于有问题。
一直觉得没有把这个东西搞清楚,终于受不了这种煎熬决定看下底层是怎么实现的。
线程/进程历史
linux系统是先有进程的概念,后有线程的概念;
linux内核里面现有的线程实现,其实是redhat这个发行版厂商的实现,合入到linux主线了,redhat的实现就是现在的glibc里面的nptl(Native POSIX Thread Library)加上内核fork共同协作实现的
除了NPTL,还有IBM的开源实现NGPT,后面没有被纳入内核,停止维护;
Linux内核也实现了Linux Threads,对比NPTL优势不明显吧
Linux线程实现概述
用户态(glibc)+内核态组合共同实现
用户态做的事情
线程attribute赋值(如果需要)
线程运行时用户态运行的栈空间分配(包括栈空间的缓存和分配,大小、栈地址)
glibc的线程对象创建以及初始化
内核态做的事情
clone系统调用在调用do_fork时,主要flag有:CLONE_VM | CLONE_FS | CLONE_FILES
CLONE_VM表示和创建线程的进程共享地址空间
CLONE_FS表示和创建线程的进程共享文件系统(根目录、工作目录、文件创建掩码等)
CLONE_FILES表示和创建线程的进程共享打开的文件
创建线程用的clone和创建真进程fork的区别是什么
fork在内核也是调用do_fork函数,fork系统调用在调用do_fork创建新的进程时,只携带了SIGCHLD这一个flag,其实就是不共享地址空间、打开的文件、使用的文件系统等,其它的和clone基本没有区别。
说线程是轻量级进程就是这个原因,和创建它的进程共享了一部分主要资源。
后文把线程、轻量级进程、进程统一称为进程,linux系统里面进程是内核支持的,线程是轻量级进程的别名(我认为这样好理解,没有官方说法支持)
用户态和内核态如何配合达到效果
进程是对CPU的虚拟化,CPU是用来执行二进制程序的,二进制可以是用户态也可以是内核态;也就是进程既可以执行用户态代码,也可以执行内核态代码,进程是一个逻辑概念,内核态/用户态代码是表示运行指令时CPU处在不同的保护模式(ring0还是ring3)
进程是由内核态创建,进程主要作用是做为调度一员,获取CPU时间片、地址空间、文件系统、打开文件、进程权限、用户权限、cpu亲和性、调度算法、进程组、运行时间、命名空间等信息(其它信息可以看task_struct的定义)
进程如何创建和运行起来
long do_fork(...)
{struct task_struct *p;p = copy_process(clone_flags, stack_start, stack_size,child_tidptr, NULL, trace, NUMA_NO_NODE);wake_up_new_task(p);}
依据clone_flags(用户态或者系统调用时添加的flags)的不同使用copy_process创建一个新的进程p;
新创建的进程p执行了wake_up_new_task后就会被调度进程调度运行,进程被调度就会分配CPU运行。
如何执行用户态线程函数
用户态线程函数如何触发执行
glibc里面pthread_create在X86_64的实现里面会调用__clone函数(clone.S,clone的实现之一)
1、用户线程函数地址调用pthread_create后如何保存
用户调用glibc函数创建线程时传递下来的fn地址是放在rdi这个寄存器(clone.S注释有说明,应该也是遵循ABI函数调用堆栈参数保存约定的)。
注意这里的fn其实是glibc的start_thread函数,这个函数再调用户的线程函数,指针保存在start_routine成员里面,具体代码如下
if (pd->c11){/* The function pointer of the c11 thread start is cast to an incorrecttype on __pthread_create_2_1 call, however it is casted back to correctone so the call behavior is well-defined (it is assumed that pointersto void are able to represent all values of int. */int (*start)(void*) = (int (*) (void*)) pd->start_routine;ret = (void*) (uintptr_t) start (pd->arg);}
elseret = pd->start_routine (pd->arg);
2、fn如何被调用
fn是用户态地址,确实没有必要传递到内核函数里面在内核态调用,这样就牵扯到地址转换了
那如何调用?在clone系统调用之前,把fn地址压到子进程的栈里面,系统调用返回后,再弹出地址,通过call执行函数,相关代码为:
ENTRY (__clone)......movq %rdi,0(%rsi)............movl $SYS_ify(clone),%eax....../* End FDE now, because in the child the unwind info will bewrong. */cfi_endproc;syscalltestq %rax,%raxjl SYSCALL_ERROR_LABELjz L(thread_start)retL(thread_start):popq %rax /* Function to call. */popq %rdi /* Argument. */call *%rax
内核态如何找到用户态的栈
glibc创建栈空间后,栈起始地址以及栈大小会通过系统调用的参数传递到内核
(如上面copy_process函数的第二个和第三个参数)
接下来内核调用copy_thread(clone_flags, stack_start, stack_size, p)(不同的架构对应不同的实现)
*childregs = *current_pt_regs();childregs->ax = 0;
if (sp)childregs->sp = sp;
这里对应的是X86_64的实现,可以看到把用户态传下来的栈指针设置到栈寄存器里面
这里有个点,这个sp和上面保存fn的栈是一个吗?是用户态分配的栈吗?
我认为是的,这个是已经push过参数和fn之后的栈。
其它
为什么线程的栈要用户分配在用户态?
A:栈要么分在用户态,要么分在内核态
如果分在用户态,线程主循环函数运行在用户态,都是在用户态,就不需要CPU模式切换,运行速度更快,都是用户态代码也不存在对内核的安全问题
如果分在内核态,那用户态函数执行,函数执行要不停操作栈,那至少就会有内核地址到用户态地址的转换,要不要牵扯模式切换就另说了。