从源码角度看Linux线程是怎么创建出来的
这篇文章来学习一下线程的创建过程。
线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。
用户态创建线程
pthread_create 不是一个系统调用,是 glibc 库的一个函数,位于 nptl/pthread_create.c 中:
那这个函数到底做了什么呢?
首先是线程的属性参数,如果没有传入线程属性,就取默认值:
接下来,就像在内核里一样,每一个进程或者线程都有一个 task_struct 结构,在用户态也有一个用于维护线程的结构,就是这个 pthread 结构:
凡是涉及到函数的调用,都要使用自己的栈。每个线程都有自己的栈。因此需要创建线程栈:
ALLOCATE_STACK 是一个宏,我们找到它的定义之后,发现它其实是一个函数:
如果你在线程属性里面设置过栈的大小,需要你把设置的值拿出来
为了防止栈的访问越界,在栈的末尾会有一块空间 guardsize,一旦访问到这里就错误了
其实线程栈是在进程的堆里面创建的。如果一个进程不断的创建和删除线程,我们不可能不断地去申请和清除线程栈使用的内存块,这样就需要有一个缓存。get_cached_stack 就是根据计算出来的 size 的大小,看看已经有的缓存中,有没有已经能够满足条件的
如果缓存里面没有,就需要调用 __mmap 创建一块新的(如果要在堆里面 malloc 一块内存,比较大的话,就用 __mmap)
线程栈也是自顶而下生长的,在栈底的位置,其实是地址的最高位
每个线程都要有一个 pthread 结构,这个结构也是放在栈的空间里面的
计算出 guard 内存的位置,调用 setup_stack_prot 设置这块内存的是受保护的
接下来,开始填充 pthread 这个结构里面的成员变量 tackblock、stackblock_size、guardsize、specific。这里的 specific 是用于存放 Thread Specific Data 的,也即属于线程的全局变量
将这个线程栈放到 stack_used 链表中。
其实管理线程栈总共有两个链表,一个是 stack_used,也就是这个栈正在被使用;另一个是 stack_cache,一旦线程结束,先缓存起来,不释放,等有其他的线程创建的时候,给其他的线程用
搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。
【文章福利】小编推荐自己的Linux内核技术交流群:【749907784】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!(含视频教程、电子书、实战项目及代码)


内核态创建任务
接下来,我们接着 pthread_create 看。其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题
start_routine 就是我们给线程的函数,start_routine 、start_routine 的参数 arg,以及调度策略都要赋值给 pthread。
接下来 __nptl_nthreads 加 1,说明又多了一个线程。
真正创建线程的是调用 create_thread 函数,这个函数定义如下:
这里面有很长的 clone_flags 需要特别关注一下。
然后就是 ARCH_CLONE ,其实就是调用 __clone(如果对于汇编不太熟悉也没关系,重点看注释)
我们能看到最后调用了 syscall,这一点 clone 和其他系统调用几乎一模一样,但是也有一些不一样的地方:
如果在进程的主线程里面调用了其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用 clone 的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
但是对于线程来说,这些都要变。因为我们希望当 clone 这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用1返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该执行线程的栈,指令指针应该指向线程将要执行的那个函数
所以这些都要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里面弹出来的时候,就从这个函数开始,带着这些参数执行下去。
接下来我们就要进入内核了。内核里面对于 clone 系统调用的定义是这样的:
可以看到,调用了 _do_fork,先前我们已经看过了一遍它的主要逻辑,现在我们重点关注几个区别。
第一个是上面复杂的标识位设定,我们来看都影响了什么。
对于 copy_files,原来是调用 dup_fd 复制一个 files_struct 的,现在因为 CLONE_FILES 标识位变成将原来的files_struct 引用计数加 1
对于 copy_fs,原来是调用 copy_fs_struct 复制一个 fs_struct,现在因为 CLONE_FS 标识位,变成将原来的 fs_struct 的用户数加 1
对于 copy_sighand,原来是创建一个新的 sighand_struct,现在因为 CLONE_SIGHAND 标识位变成将原来的sighand_struct 引用计数加 1
对于 copy_signal,原来是创建一个新的 signal_struct,现在因为 CLONE_THREAD 直接返回了
对于 copy_mm,原来是调用 dup_mm 复制一个 mm_struct,现在因为 CLONE_VM 标识位而直接指向了原来的mm_struct
第二个是对于亲缘关系的影响,毕竟我们要识别多个线程是不是属于一个进程。
从上面可以看出,使用了 CLONE_THREAD 标识位之后,使得亲缘关系有了一定的变化。
如果是新进程,那这个进程的 group_leader 就是它自己,tgid 就是它自己的 pid,也就是说,这就完全重打锣鼓另开张了,自己是线程组的头;如果是新线程,group_leader 是当前进程的 group_leader,tgid 是当前进程的 tgid,也就是当前进程的 pid,这个时候还是拜原来进程为老大。
如果是新进程,新进程的 real_parent 就是当前的进程,也就是新进程是子辈;如果是新线程,线程的 real_parent 是当前的进程的 real_parent,其实是平辈的。
第三,对信号的处理,如何保证发给进程的信号虽然可以被一个线程处理,但是影响范围应该是整个进程的。比如,kill一个进程,则所有线程都要被干掉。如果 pthread_kill 一个线程,那只有那个线程才能够收到。
在 copy_process 的主流程里面,无论是创建进程还是线程,都会初始化 struct sigpending pending,也就是每个task_struct,都会有这样一个成员变量。这就是一个信号列表。如果这个 task_struct 是一个线程,这里面的线程就是发给这个线程的;如果 task_struct 是一个进程,那这里面的信号是发给主线程的。
另外,上面 copy_signal 的时候,我们可以看到,在创建进程的过程中,会初始化 signal_struct 里面的 struct sigpending shared_pending。但是,在创建线程的过程中,连 signal_struct 都共享了。也就是说,整个进程里的所有线程共享一个 shard_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样
至此,clone 在内核的调用完毕,要返回系统调用,回到用户态。
用户态执行线程
根据 clone 的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的 start_thread,这是所有线程在用户态的统一入口
在 start_thread 入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。比如,线程本地数据 thread_local ,线程数目也减少 1。如果这是最后一个线程了,就直接退出进程,另外 __free_tcb 用于释放 pthread
线程栈的列表 stack_used 中拿下来,放到缓存的线程栈列表 stack_cache 中。
至此,整个线程的生命周期结束。
总结
下表对比了创建进程和创建线程在用户态和内核态的不同。
创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。
创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面,五大结构仅仅是引用计数加一,也就是线程共享进程的数据结构

