19 | 线程的创建:如何执行一个新子项目?
下载APP
关闭
渠道合作
推荐作者
19 | 线程的创建:如何执行一个新子项目?
2019-05-10 刘超 来自北京
《趣谈Linux操作系统》
课程介绍
讲述:刘超
时长13:08大小12.00M
上一节,我们了解了进程创建的整个过程,今天我们来看线程创建的过程。
我们前面已经写过多线程编程的程序了,你应该都知道创建一个线程调用的是 pthread_create,可你知道它背后的机制吗?
用户态创建线程
你可能会问,咱们之前不是讲过了吗?无论是进程还是线程,在内核里面都是任务,管起来不是都一样吗?但是问题来了,如果两个完全一样,那为什么咱们前两节写的程序差别那么大?如果不一样,那怎么在内核里面加以区分呢?
其实,线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。pthread_create 不是一个系统调用,是 Glibc 库的一个函数,所以我们还要去 Glibc 里面去找线索。
果然,我们在 nptl/pthread_create.c 里面找到了这个函数。这里的参数我们应该比较熟悉了。
下面我们依次来看这个函数做了些啥。
首先处理的是线程的属性参数。例如前面写程序的时候,我们设置的线程栈大小。如果没有传入线程属性,就取默认值。
接下来,就像在内核里一样,每一个进程或者线程都有一个 task_struct 结构,在用户态也有一个用于维护线程的结构,就是这个 pthread 结构。
凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈。那接下来就是创建线程栈了。
ALLOCATE_STACK 是一个宏,我们找到它的定义之后,发现它其实就是一个函数。只是,这个函数有些复杂,所以我这里把主要的代码列一下。
我们来看一下,allocate_stack 主要做了以下这些事情:
如果你在线程属性里面设置过栈的大小,需要你把设置的值拿出来;
为了防止栈的访问越界,在栈的末尾会有一块空间 guardsize,一旦访问到这里就错误了;
其实线程栈是在进程的堆里面创建的。如果一个进程不断地创建和删除线程,我们不可能不断地去申请和清除线程栈使用的内存块,这样就需要有一个缓存。get_cached_stack 就是根据计算出来的 size 大小,看一看已经有的缓存中,有没有已经能够满足条件的;
如果缓存里面没有,就需要调用 __mmap 创建一块新的,系统调用那一节我们讲过,如果要在堆里面 malloc 一块内存,比较大的话,用 __mmap;
线程栈也是自顶向下生长的,还记得每个线程要有一个 pthread 结构,这个结构也是放在栈的空间里面的。在栈底的位置,其实是地址最高位;
计算出 guard 内存的位置,调用 setup_stack_prot 设置这块内存的是受保护的;
接下来,开始填充 pthread 这个结构里面的成员变量 stackblock、stackblock_size、guardsize、specific。这里的 specific 是用于存放 Thread Specific Data 的,也即属于线程的全局变量;
将这个线程栈放到 stack_used 链表中,其实管理线程栈总共有两个链表,一个是 stack_used,也就是这个栈正被使用;另一个是 stack_cache,就是上面说的,一旦线程结束,先缓存起来,不释放,等有其他的线程创建的时候,给其他的线程用。
搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。
内核态创建任务
接下来,我们接着 pthread_create 看。其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题。
start_routine 就是咱们给线程的函数,start_routine,start_routine 的参数 arg,以及调度策略都要赋值给 pthread。
接下来 __nptl_nthreads 加一,说明又多了一个线程。
真正创建线程的是调用 create_thread 函数,这个函数定义如下:
这里面有很长的 clone_flags,这些咱们原来一直没注意,不过接下来的过程,我们要特别的关注一下这些标志位。
然后就是 ARCH_CLONE,其实调用的是 __clone。看到这里,你应该就有感觉了,马上就要到系统调用了。
如果对于汇编不太熟悉也没关系,你可以重点看上面的注释。
我们能看到最后调用了 syscall,这一点 clone 和我们原来熟悉的其他系统调用几乎是一致的。但是,也有少许不一样的地方。
如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用 clone 的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
但是对于线程来说,这些都要变。因为我们希望当 clone 这个系统调用成功的时候,除了内核里面有这个线程对应的 task_struct,当系统调用返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该指向线程的栈,指令指针应该指向线程将要执行的那个函数。
所以这些都需要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。
接下来我们就要进入内核了。内核里面对于 clone 系统调用的定义是这样的:
看到这里,发现了熟悉的面孔 _do_fork,是不是轻松了一些?上一节我们已经沿着它的逻辑过了一遍了。这里我们重点关注几个区别。
第一个是上面复杂的标志位设定,我们来看都影响了什么。
对于 copy_files,原来是调用 dup_fd 复制一个 files_struct 的,现在因为 CLONE_FILES 标识位变成将原来的 files_struct 引用计数加一。
对于 copy_fs,原来是调用 copy_fs_struct 复制一个 fs_struct,现在因为 CLONE_FS 标识位变成将原来的 fs_struct 的用户数加一。
对于 copy_sighand,原来是创建一个新的 sighand_struct,现在因为 CLONE_SIGHAND 标识位变成将原来的 sighand_struct 引用计数加一。
对于 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 都共享了。也就是说,整个进程里的所有线程共享一个 shared_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样。
至此,clone 在内核的调用完毕,要返回系统调用,回到用户态。
用户态执行线程
根据 __clone 的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的 start_thread,这是所有线程在用户态的统一入口。
在 start_thread 入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。例如,线程本地数据 thread_local variables,线程数目也减一。如果这是最后一个线程了,就直接退出进程,另外 __free_tcb 用于释放 pthread。
__free_tcb 会调用 __deallocate_stack 来释放整个线程栈,这个线程栈要从当前使用线程栈的列表 stack_used 中拿下来,放到缓存的线程栈列表 stack_cache 中。
好了,整个线程的生命周期到这里就结束了。
总结时刻
线程的调用过程解析完毕了,我画了一个图总结一下。这个图对比了创建进程和创建线程在用户态和内核态的不同。
创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
课堂练习
你知道如果查看一个进程的线程以及线程栈的使用情况吗?请找一下相关的命令和 API,尝试一下。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 15
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
18 | 进程的创建:如何发起一个新项目?
下一篇
20 | 内存管理(上):为客户保密,规划进程内存空间布局
精选留言(33)
- Milittle2019-05-10刘老师,您好,您可以把文档中给出的代码文件定位给出来么,一般在对应看源码的时候,很难定位到老师给的代码点的对应源码文件,谢谢老师~老师讲的真的让我把多年的零散知识可以连贯起来,然后理解的更加透彻,但是也会有不太理解的地方,再次感谢。这个课很值得~。 总结以下进程和线程的异同点: 1. 进程有独立的内存空间,比如代码段,数据段。线程则是共享进程的内存空间。 2. 在创建新进程的时候,会将父进程的所有五大数据结构复制新的,形成自己新的内存空间数据,而在创建新线程的时候,则是引用进程的五大数据结构数据,但是线程会有自己的私有(局部)数据,执行栈空间。 3. 进程和线程其实在cpu看来都是task_struct结构的一个封装,执行不同task即可,而且在cpu看来就是在执行这些task时候遵循对应的调度策略以及上下文资源切换定义,包括寄存器地址切换,内核栈切换,指令指针寄存器的地址切换。所以对于cpu而言,进程和线程是没有区别的。 4. 进程创建的时候直接使用系统调用fork,进行系统调用的链路走,从而进入到_do_fork去创建task,而线程创建在调用_do_fork之前,还需要维护pthread这个数据结构的信息,初始化用户态栈信息。 自己就能意识到这几点,如果有理解不到位,或者不全面的地方,还请老师给予指点,谢谢老师。展开共 7 条评论44
- jacy2019-08-01pstree -apl pid看进程树 pstack pid 看栈
作者回复: 赞
27 - why2019-05-14- 线程的创建 - 线程是由内核态和用户态合作完成的, pthread_create 是 Glibc 库的一个函数 - pthread_create 中 1. 设置线程属性参数, 如线程栈大小 2. 创建用户态维护线程的结构, pthread 3. 创建线程栈 allocate_stack - 取栈的大小, 在栈末尾加 guardsize - 在进程堆中创建线程栈(先尝试调用 get_cached_stack 从缓存回收的线程栈中取用) - 若无缓存线程栈, 调用 `__mmap` 创建 - 将 pthread 指向栈空间中 - 计算 guard 内存位置, 并设置保护 - 填充 pthread 内容, 其中 specific 存放属于线程的全局变量 - 线程栈放入 stack_used 链表中(另外 stack_cache 链表记录回收缓存的线程栈) 4. 设置运行函数, 参数到 pthread 中 5. 调用 create_thread 创建线程 - 设置 clone_flags 标志位, 调用 `__clone` - clone 系统调用返回时, 应该要返回到新线程上下文中, 因此 `__clone` 将参数和指令位置压入栈中, 返回时从该函数开始执行 6. 内核调用 `__do_fork` - 在 copy_process 复制 task_struct 过程中, 五大数据结构不复制, 直接引用进程的 - 亲缘关系设置: group_leader 和 tgid 是当前进程; real_parent 与当前进程一样 - 信号处理: 数据结构共享, 处理一样 7. 返回用户态, 先运行 start_thread 同样函数 - 在 start_thread 中调用用户的函数, 运行完释放相关数据 - 如果是最后一个线程直接退出 - 或调用 `__free_tcb` 释放 pthread 以及线程栈, 从 stack_used 移到 stack_cache 中展开共 3 条评论27
- why2019-05-14老师, 多线程的内核栈是共享的吗, 会不会出现问题?
作者回复: 不共享,进了内核都是单独的任务了
共 2 条评论15 - humor2019-05-30老师之前说过进程默认会有一个主线程,意思是在创建进程的时候也会同时创建一个线程吗?
作者回复: 不会,这个进程的task_struct就代表这个线程
共 2 条评论10 - 徐凯2019-05-10"将这个线程栈放到 stack_used 链表中,其实管理线程栈总共有两个链表,一个是 stack_used,也就是这个栈正被使用;另一个是 stack_cache,就是上面说的,一旦线程结束,先缓存起来,不释放,等有其他的线程创建的时候,给其他的线程用。" 这一段是线程池的意思么 如果是的话 既然内部已经有这个设计 我们有时候还要在程序中自己去设计一个呢?
作者回复: 内核没有线程池的概念,把线程弄一个池子,是业务层做的。这里只是内核栈的复用。
6 - 蹦哒2020-06-14老师、同学们,不知道如下认识是否正确呢: 1.原来线程存在的价值是复用进程的部分内存(引用五大结构),又是一个享元模式(Flyweight Design Pattern)的体现 2.线程函数局部变量在用户态的线程栈中(是在进程的堆里面创建的),独立的内存块,所以多线程之间无需考虑共享数据问题;而进程的全局变量,由于多线程是共享了进程数据,再加上各个线程在内核中是独立的task被调度系统调度,随时会被抢占并且访问同一个全局变量,所以多线程之间需要做共享数据保护展开
作者回复: 是的
6 - neohope2019-12-10关于clone_flags标志位的含义,可以参考一下这里http://man7.org/linux/man-pages/man2/clone.2.html If CLONE_THREAD is set, the child is placed in the same thread group as the calling process. When a clone call is made without specifying CLONE_THREAD, then the resulting thread is placed in a new thread group whose TGID is the same as the thread's TID. This thread is the leader of the new thread group. If CLONE_PARENT is set, then the parent of the new child (as returned by getppid(2)) will be the same as that of the calling process. If CLONE_PARENT is not set, then (as with fork(2)) the child's parent is the calling process.展开4
- Geek_b8928e2020-03-22创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。3
- garlic2019-10-12进程线程查看命令:ps,top,pidstat,pstree 函数栈查看打印命令: pstack jstack (java) gdb (C/C++/go) kill -SIGQUIT [pid] (go) 相关API: C: glibc backtrace Boost stacktrace libunwind Java: getStackTrace; go: panic debug.PrintStack pprof.Lookup("goroutine").WriteTo runtime.Stack python traceback objects StackSummary Objects 笔记链接 https://garlicspace.com/?p=1678&preview=true展开3
- 兔嘟嘟2021-08-19老师您好,我想请问一下可以这样理解主线程和进程的关系吗: 主线程和进程是绑定的,在内核体现为一个task,在用户态则是进程的运行主线,所以其实没有独立的主线程的概念。 主线程的线程栈就是进程栈,主线程没有pthread,所有的属性都是进程的属性。 因为主线程其实不是独立的,所以主线程结束时,也就是进程结束时。2
- 相逢是缘2020-08-151、pthread_create 不是一个系统调用,是 Glibc 库的一个函数 2、在内核里一样,每一个进程或者线程都有一个 task_struct 结构,线程在用户态也有一个用于维护线程的结构,就是这个 pthread 结构 3、创建线程栈 ----用户态int err = ALLOCATE_STACK (iattr, &pd); ---程属性里面设置过栈的大小,需要你把设置的值拿出来 ---为了防止栈的访问越界,在栈的末尾会有一块空间 guardsize ---其实线程栈是在进程的堆里面创建的get_cached_stack ---如果缓存里面没有,就需要调用 __mmap 创建一块新的 ---线程栈也是自顶向下生长的,还记得每个线程要有一个 pthread 结构,这个结构也是放在栈的空间里面的。在栈底的位置,其实是地址最高位。 ---计算出 guard 内存的位置,调用 setup_stack_prot 设置这块内存的是受保护的 ---开始填充 pthread 这个结构里面的成员变量 stackblock、stackblock_size、guardsize、specific。 ---将这个线程栈放到 stack_used 链表中,使用完之后放到stack_cache中 ---其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题 pd->start_routine = start_routine; pd->arg = arg; pd->schedpolicy = self->schedpolicy; pd->schedparam = self->schedparam; /* Pass the descriptor to the caller. */ *newthread = (pthread_t) pd; atomic_increment (&__nptl_nthreads); retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran); start_routine 就是咱们给线程的函数,start_routine,start_routine 的参数 arg,以及调度策略都要赋值给 pthread ----内核态 --系统调用__clone 将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始 --在 copy_process 复制 task_struct 过程中, files、fs、sighand、mm、五大数据结构不复制, 直接引用进程的 --亲缘关系:新进程group_leader就是自己,tgid就是他的pid,real_parent 是当前的进程。新线程group_leader是当前进程的,tgid是当前进程的tgid,real_parent 是当前集成的real_parent; --信号处理:共享信号 4、用户态执行线程 --所有的线程统一的入口start_thread --用户的函数执行完毕之后,会释放这个线程相关的数据 a、线程数目也减一,如果这是最后一个线程了,就直接退出进程 b、_free_tcb 用于释放 pthread,__free_tcb 会调用 __deallocate_stack 来释放整个线程栈,放到缓存的线程栈列表 stack_cache 中;展开2
- kdb_reboot2019-09-28三刷: 感觉应该讲清楚这一点,"角色划分" 内核态是用来管理的,用户态是提供给用户用的 这才有了为什么要两个模式来回切换, 以及,真正调度,是内核态来做的,而用户态执行是用户态自己做,这才有了单独的线程栈 内存模型也很重要 另外想请教个问题:从上下文来理解, 所以说,主线程的栈是用整个用户空间的栈?子线程的栈在进程的堆里面?展开2
- lfn2019-05-10所以,线程局部变量其实是存储在每个线程自己的用户栈里咯?
作者回复: 是的
2 - Geek_4c6cb82022-02-23老师您好,同一个进程的不同线程,内核栈都是相同的吗共 1 条评论1
- 空格2019-09-18不知道我的理解对不对?线程fork后内核态会创建task_struct,之后还是会尝试wakeup_preempt_entity,然后之后接受调度,调度成功后才会在用户态执行start_thread方法?不知道我这个理解对不对?1
- nora2019-05-18之前总是认为线程和进程都占用了内核的taskstruct 认为实际上线程进程没啥区别,这篇文章真是醍醐灌顶啊,谢谢老师。
作者回复: 赞,加油
1 - 浅陌2022-06-30请问这个线程是内核态还是用户态的呀,用户态线程与内核态线程多对多是怎么实现的呀
- 小鳄鱼2022-06-17老师,可否讲一下线程模型?还有LWP是如何支持用户线程
- Samaritan.2022-05-02老师每一讲的图真的是太用心了,以前有很多模糊的地方,看着图非常容易理解