Linux进程(四):线程
前言
在记录这篇博客之前,针对pthread_create
函数创建的线程,网上的说法不一,有人说是用户级线程,有人说是LWP。在我亲自实验后发现,由pthread_create
创建出来的线程是一个轻量级进程,只不过在这个机制上,Linux 2.6在task_struct
中新加入了tgid
这么一个东西来欺骗了大家。
线程的概念
在线程的概念中,线程分为内核线程、轻量级进程和用户线程。前两者的调度都由内核执行并完成,而后者的调度则需要用户态的函数库实现。
内核线程
内核线程由内核的内部需求进行创建和撤销,是独立运行在内核空间的标准进程。内核线程和普通的线程的区别就在于内核线程的mm
指针被置为NULL,且其只在内核空间运行,不会切换到用户空间。实际上,内核线程只能由其它内核线程创建,Linux驱动模块中可以用kernel_thread()
,kthread_create()
/kthread_run
两种方式创建内核线程。
一个内核线程不需要和一个用户进程联系起来,它共享内核的正文段和全局数据,具有自己的内核堆栈。内核线程的调度由于不需要经过态的转换并进行地址空间的重新映射,因此在内核线程建做上下文切换比在进程间做上下文切换要快许多。
轻量级进程LWP
既然叫做进程,那么它确实就是进程,也就是说每个LWP中都有属于自己的一个pid
,但它与普通的进程相比,轻量级进程与父进程共享了几乎所有的资源(这可以在do_fork()
函数的标志位参数中体现)。LWP有它自己的进程标识符,也有自己的父进程。在Linux2.0.x版本中就已经实现了轻量级进程:应用程序可以通过clone()
函数灵活性地创建进程。
在大多数系统中,LWP与普通进程的区别在于它只有一个最小的执行上下文和调度程序所需的统计信息,而这也是它被称为“轻量级”的原因。
用户线程
用户线程是在没有内核参与的情况下创建、释放和管理的线程。线程库提供了同步和调度的方法,这样进程可以使用大量的线程而不消耗内核资源,而且省去大量的系统开销。用户线程的建立、同步、销毁和调度完全在用户空间完成,不需要内核的帮助,因此这种线程的操作是极其快速的跑且低消耗的。
如下为一个用户线程切换的例子:
__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"
);
区别
用户态线程和内核态线程主要的区别就在于线程由谁来管理:用户态线程时用户管理,内核态线程是内核管理(注:此处的管理包括创建、调度和销毁等操作)。
两者的区别在于:
- 可移植性:因为ULT完全在用户态实现线程,因此也就和具体的内核没有什么关系,可移植性方面ULT略胜一筹;
- 可扩展性:ULT是由用户控制的,因此扩展也就容易;相反,KLT扩展就很不容易,基本上只能受制于具体的操作系统内核;
- 性能:由于ULT的线程是在用户态,对应的内核部分还是一个进程,因此ULT就没有办法利用多处理器的优势,而KLT就可以通过调度将线程分布在多个处理上运行,这样KLT的性能高得多;另外,一个ULT的线程阻塞,所有的线程都阻塞,而KLT一个线程阻塞不会影响其它线程。
- 编程复杂度:ULT的所有管理工作都要由用户来完成,而KLT仅仅需要调用API接口,因此ULT要比KLT复杂的多。
关于pthread线程库
对于pthread
线程库的发展历史,reference中的第一篇文章对其进行了详细的描述,笔者在此做出简单总结:
注:在Linux2.0~2.4实现的是俗称LinuxThread的多线程方式,到了2.6,基本上都是NPTL的方式了。
LinuxThread
在LinuxThread中,进程中的每个线程都由这个进程的管理线程进行管理。当进程第一次调用pthread_create()
创建一个线程的时候就会创建并启动管理线程。然后又管理线程再来创建用户请求的线程。也就是说,用户在调用pthread_create()
后,先是创建了管理线程,再由管理线程创建了用户的线程。
因此我们若运行A程序创建了10个线程,那么在shell
下执行ps
命令时将看到11个A进程,而不是1个或10个,且不管是kill
还是pthread_kill
,信号只能被一个对应的线程所接收。在LinuxThread中,SIGSTOP
、SIGCONT
信号只对一个线程起作用。
LinuxThread对于POSIX标准,仅仅实现了一点:当“进程”收到一个致命信号,对应的这一组task_struct将全部退出。
为了实现这一点,LinuxThread付出了很多代价:当程序第一次调用pthread_create
时,LinuxThread发现管理线程不存在,于是创建一个管理线程,这个管理线程是进程中的第一个线程(主线程)的儿子。然后在该进程的每一次pthread_create
中,都会通过pipe
向管理线程发送一个命令,告诉它创建线程,也就是说所有的线程都是管理线程的儿子。
当一个子线程退出时,管理线程将收到SIGUSER1
信号,管理线程在对应的sig_handler
中判断子线程是否正常退出,如果不是,则杀死所有线程,然后自杀。同时,管理线程会在主循环中通过getppid
检查父进程的ID号,如果ID号是1,说明父进程已退出并把自己托管给了init
进程,这时候,管理线程也将杀掉所有子线程,然后自杀。
可见,创建于销毁西药一次进程间通信,一次上下文切换之后才能被管理线程执行,并且多个请求会被管理线程串行执行。
LinuxThread存在以下问题:
线程ID和进程ID的问题
按照POSIX的定义,同一进程的所有的线程应该共享同一个进程和父进程ID,而Linux的这种LWP方式显然不能满足这一点。
信号处理问题
异步信号是以进程为单位分发的,而Linux的线程本质上每个都是一个进程,且没有进程组的概念,所以某些缺省信号难以做到对所有线程有效,例如SIGSTOP和SIGCONT,就无法将整个进程挂起,而只能将某个线程挂起。
线程总数问题
LinuxThreads将每个进程的线程最大数目定义为1024,但实际上这个数值还受到整个系统的总进程数限制,这又是由于线程其实是核心进程。
管理线程问题
管理线程容易成为瓶颈,这是这种结构的通病;同时,管理线程又负责用户线程的清理工作,因此,尽管管理线程已经屏蔽了大部分的信号,但一旦管理线程死亡,用户线程就不得不手工清理了,而且用户线程并不知道管理线程的状态,之后的线程创建等请求将无人处理。
同步问题
LinuxThreads中的线程同步很大程度上是建立在信号基础上的,这种通过内核复杂的信号处理机制的同步方式,效率一直是个问题。
其他POSIX兼容性问题
Linux中很多系统调用,按照语义都是与进程相关的,比如nice、setuid、setrlimit等,在目前的LinuxThreads中,这些调用都仅仅影响调用者线程。
实时性问题
线程的引入有一定的实时性考虑,但LinuxThreads暂时不支持,比如调度选项,目前还没有实现。不仅LinuxThreads如此,标准的Linux在实时性上考虑都很少
NPTL
在Linux 2.6中,内核有了线程组的概念,task_struct
结构体中增加了一个tgid(thread group id)
字段:如果这个task是一个“主线程”,则它的tgid
等于gid
,否则它的tgid
等于进程的pid
。达到这个效果的方法就是在clone
系统调用中,传递CLONE_THREAD
参数就可以把新进程的tgid
设置为父进程的tgid
。注:类似xxid在task_struct
中还有两个:task_signal_pgid
保存进程组的打头进程的pid
、task->signal->ssion
保存会话打头进程的pid
。
有了tgid
这个东西,Linux内核就能将所有线程“伪造”成一个进程了,并且内核也将知道什么时候应该展现什么pid
。例如接下来的这个例子。
伪装
前面我们说到每一个线程其实对应了一个task_struct
,所以他们在内核中必然都有着属于自己的pid
,但当我们在用户空间中去使用top
、ps
等命令时,在PID
那一栏显示的是进程的tgid
,同样当我们在用户空间去getpid()
时,也会被Linux内核所“欺骗”从而得到tgid
。
那有没有什么方法能验证这一点呢?既然我们在用户态看不到线程真正的pid
,那凭什么说getpid()
返回的pid
是假的呢?其实除了getpid()
这个函数以外,我们可以通过系统调用来获取的进程真正的pid
,通过这个函数,我们可以看到每个线程的task_struct
在内核中的真正的pid
。我们可以用一个小程序来验证这一观点:
static pid_t gettid( void )
{
return syscall(__NR_gettid);
}
static void *thread_fun(void *param)
{
printf("thread fake pid:%d, real pid:%d pthread_self:%lu\n", getpid(), gettid(),pthread_self());
while(1);
return NULL;
}
int main(void)
{
pthread_t tid1, tid2;
int ret;
printf("thread fake pid:%d, real pid:%d pthread_self:%lu\n", getpid(), gettid(),pthread_self());
ret = pthread_create(&tid1, NULL, thread_fun, NULL);
if (ret == -1) {
perror("cannot create new thread");
return -1;
}
ret = pthread_create(&tid2, NULL, thread_fun, NULL);
if (ret == -1) {
perror("cannot create new thread");
return -1;
}
if (pthread_join(tid1, NULL) != 0) {
perror("call pthread_join function fail");
return -1;
}
if (pthread_join(tid2, NULL) != 0) {
perror("call pthread_join function fail");
return -1;
}
return 0;
}
在这个程序中的main()
函数中调用了getpid()
和gettid()
,其中gettid()
就是去获取内核中真正的pid
。同时还调用了pthread_self()
,这个是pthread
库中提供给每个线程的id(纯用户空间pthread
库中的概念,和内核无关)。并且我们在主函数中创建线程,并且各个线程也会调用getpid()
、gettid()
和pthread_self()
这三个函数。
在我们运行这个程序之后,我们可以看到输出结果:
果然,当我们在用户态调用getpid()
函数时,看到的pid都相同,Linux想让我们误认为这些线程都是同一个进程,然而我们可以看到real pid
那一栏:当我们调用系统调用去查看每个检查真正的pid
时,我们会发现其实各自的pid
是不同的!
同时我们可以看到每个线程的fake pid
都是相同的,并且都等于主线程的real pid
,这同样也验证了上面的说法:如果这个task是一个“主线程”,则它的tgid
等于gid
,否则它的tgid
等于进程的pid
。
同样,当我们使用top
这个命令去查看进程状态时,Linux会将所有属于一个线程系列的进程的资源整合成一个进程并显示:
首先我们回到测试程序:主线程在创建完两个线程之后执行pthread_join
等待子线程执行完毕,而两个子线程在输出相关信息后进入到while(1)
死循环,所以在状态表第一行中16662这个进程占到了200%的CPU(占了CPU两个核)。大家可以注意到,此刻我们仍然是把所有的线程当成一个进程看的,用户同样感受不到LWP中“进程”的存在,但是如果我们使用top -H
命令,则会得到不同的结果:
此时的top
显示的是线程视角,同时后面的状态也是每一个线程的资源使用情况,这时我们就在进程号(也就是PID,第一列)那一栏中看到了我们两个线程的real pid
。同时这两个进程(也可以说是线程)的CPU占用率都为100%(while(1)
死循环)。至于为什么看不到主线程的pid
,因为主线程此刻正在等待子线程运行结束。
同样的结果也出现在ps
命令中,以下给出ps aux
和ps -L aux
关于进程16662的描述,原理和上述一致。
ps aux
:
ps -L aux
:
有一点值得注意:在执行ps
命令的时候不展现子线程,也是有一些问题的。比如程序a.out
运行时,创建了一个线程。假设主线程的pid
是10001、子线程是10002(它们的tgid
都是10001)。这时如果你kill 10002,是可以把10001和10002这两个线程一起杀死的,尽管执行ps
命令的时候根本看不到10002这个进程。如果你不知道linux线程背 后的故事,肯定会觉得遇到灵异事件了。
而为了应付“发送给进程的信号”和“发送给线程的信号”,task_struct
里面维护了两套signal_pending
,一套是线程组共享的,一套是线程独有的,通过kill
发送的信号被放在线程组共享的signal_pending
中,可以由任意一个线程来处理;通过pthread_kill
发送的信号(pthread_kill
是pthread
库的接口,对应的系统调用中的tkill
)被放在线程独有的signal_pending
中,只能由本线程来处理。
同时在/proc/
中,在主线程的目录下的task
目录中可以看到所有该进程其它线程的目录。
reference
https://blog.youkuaiyun.com/mm_hh/article/details/72587207
https://blog.youkuaiyun.com/huangweiqing80/article/details/83088465
https://blog.youkuaiyun.com/weixin_38812277/article/details/93628751