铺垫
「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」
在面试过程中,谈谈进程、线程和协程的区别几乎是一个必考点,如果深入的话,也会让我们讲讲内核级线程和用户级线程的区别。
如果应付面试的话,这个问题也有它的一套"标准答案",比如用户线程由用户实现,内核线程由内核实现等,但就个人而言,这样的回答未免太过抽象。
对于每个程序员而言,创建进程、创建线程算是家常便饭了,调用起来也不麻烦,因为大多数语言都帮我们封装好了,那么,不同的语言创建的线程到底是用户级线程,还是内核级线程呢?
而这,就是本次探讨的主题了,由于本人知识领域有限,所以本文只会从Linux、Python、Golang这三方面进行探讨,如有错漏,希望能得到你的指正!!
应付的"标准答案"
| 用户级线程 | 内核级线程 |
|---|---|
| 由用户实现 | 由内核(操作系统)实现 |
| 内核不知道它的存在 | 内核知道它的存在 |
| 实现起来比较简单 | 实现起来比较复杂 |
| 上下文切换花费时间少 | 上下文花费时间多 |
| 一个线程阻塞,其他线程也会阻塞 | 一个线程阻塞,其他线程并不会阻塞 |
| 线程之间互相依赖 | 线程之间互相独立 |
Linux线程
在Linux中,线程是如何实现的呢?通过查阅维基百科,我发现了几个关键词:Linux Thread、POSIX Threads(Pthreads)以及Native POSIX Thread Library。
在Linux早期(内核版本2.6以前),Linux线程的实现方式是"Linux Thread",它的实现方式是通过clone系统调用创建一个共享父地址空间的新进程。这种方式导致每个线程其实具备不同的进程标识符,导致信号处理出现问题。这也是为什么有部分文章说线程本质上是进程。
当然,技术是不断进步的,关于"Linux Thread"改进的方案有两种,分别是NGPT和NPTL,两者之中最终是NPTL胜出,而NPTL即上面提到的”Native POSIX Thread Library“。
NPTL从内核版本为2.6的时候,就成为了内核的一部分,NPTL是一种 1x1 的线程库,即用户创建的线程(通过pthread_create库函数)与内核中的可调度实体 1-1 对应。
仔细揣摩这句话,其实不难得到结论:即目前常见的Linux系统中,通过pthread_create系统调用,既创建了一个用户级线程,同时也创建了一个内核级线程,两者是一一对应的。
通过以下命令也可查看是否使用了NPTL:
$ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.23
那什么是POSIX Threads(Pthreads)呢?
Pthreads其实是POSIX的线程标准,它定义了创建和操作线程的一套API,从编程语言的角度看,Pthreads就是定义线程一些操作的接口,而NPTL就是它的具体实现。
对于不同的操作系统,Pthreads的实现并不一致,这也就意味着线程的实现并不一致,所以,在探讨用户级线程和内核级线程时,我们必须明确所使用的是哪种操作系统。
Linux线程数
为了后续结论的验证,我们需要懂得查看Linux上某个进程的线程数,语法如下:
$ cat /proc/$pid/status | grep Threads
pthread_create
pthread_create属于Pthreads中一个接口,主要用于创建一个线程。
所以本次正好可以试验下pthread_create创建的线程是否如我们想的那样,是 1x1 的关系。
代码如下(C语言):
#include <stdio.h>
#include <unistd.h> //调用 sleep() 函数
#include <pthread.h> //调用 pthread_create() 函数
void *ThreadFun(void *arg)
{
if (arg == NULL) {
printf("arg is NULL\n");
}
else {
printf("%s\n", (char*)arg);
}
sleep(100);
return NULL;
}
int main()
{
int res;
char * url = "https://juejin.cn/user/1239904847949880";
//定义两个表示线程的变量(标识符)
pthread_t myThread1,myThread2;
//创建 myThread1 线程
res = pthread_create(&myThread1, NULL, ThreadFun, NULL);
if (res != 0) {
printf("线程创建失败");
return 0;
}
printf("Thread1 is created\n");
sleep(5); //令主线程等到 myThread1 线程执行完成
//创建 myThread2 线程
res = pthread_create(&myThread2, NULL, ThreadFun,(void*)url);
if (res != 0) {
printf("线程创建失败");
return 0;
}
printf("Thread2 is created\n");
sleep(50); // 令主线程等到 mythread2 线程执行完成
return 0;
}
对代码进行编译:
$ gcc thread.c -o thread_c -lpthread
执行二进制文件:
$ ./thread_c
Thread1 is created
arg is NULL
Thread2 is created
https://juejin.cn/user/1239904847949880
此时查看进程数量
$ ps -ef | grep thread_c
root 114 16 0 14:00 pts/1 00:00:00 ./thread_c
root 118 45 0 14:00 pts/2 00:00:00 grep --color=auto thread_c
$ cat /proc/114/status | grep Threads
Threads: 3
之所以线程数量为3,是因为启动一个进程的时候,同时也会启动一个线程,线程是进程的运行单位,再加上另外创建的两个线程,所以总量为3。
Python线程
讲完Linux的线程后,可能有部分朋友会有点好奇,那其他编程语言的线程库是如何实现的呢?
其实在Python中,通过thread等Python库启动的线程就是一个普通的Pthreads线程,跟上文调用pthread_create没有本质区别。
如果是这样,那讲道理Python的线程应该也能利用CPU多核的优势,那为什么很多文章都提到Python只能利用一个CPU核心呢?
原因是Python(CPython)有个”大名鼎鼎“的全局解释锁GIL,它保证了就算你有多个线程,但是同一时间只能有一个线程在运行,至于运行哪个线程,是通过竞争GIL锁决定的。
为了验证我们的结论,这里也有个例子:
import os
import time
from concurrent.futures.thread import ThreadPoolExecutor
def task(task_id):
time.sleep(200)
print('子进程开始执行,父进程id: {0},子进程id: {1},任务id:{2}'.format(os.getppid(), os.getpid(), task_id))
return os.getpid()
t = ThreadPoolExecutor(3)
task_list = []
for i in range(9):
obj = t.submit(task, i)
task_list.append(obj)
t.shutdown()
print([obj.result() for obj in task_list])
执行程序:
$ python3 thread_test.py &
[1] 29029
查看线程数量:
$ cat /proc/29029/status | grep Threads
Threads: 10
之所以线程数量为10,是因为启动一个进程的时候,同时也会启动一个线程,线程是进程的运行单位,再加上另外创建的9个线程,所以总量为10。
Golang线程
Golang里面的线程实现与其他语言比较不一样,准确来说,Go的并发术语只有Goroutine,而Goroutine更像是一个协程,而非一个线程。
但深入了解Goroutine的调度原理,可以发现其实它是基于一个GMP模型,G是Goroutine,M是我们的内核级线程,P是一个处理队列,真正执行任务的是M,M和P是一一对应的。
所以Go的线程并不是 1x1 模型,而是 MxN 模型,多个Goroutine对应多个内核级线程,至于对应多少个,有Go的调度器决定。
以项目中的一个真实Go程序为例,首先通过pprof查看其Goroutine数量:
再查看其在Linux系统上的线程数量:
$ cat /proc/28/status | grep Threads
Threads: 9
可以看到,两者并不是对等,从而验证了我们上述的结论。
写在最后
本文主要探讨内核级线程和用户级线程的区别,并从Linux、Python、Golang三个角度进行剖析,可能有些人会看得云里雾里,所以这里再次做个总结:
首先,从表现形式看,用户级线程就是处于用户态的线程,而内核级线程就是处于内核态的线程,但两者并不是完全独立的。
在讨论两者的区别时,还需要明确是在哪种环境,哪种平台下,就如本文所讨论的Linux环境(内核2.6+),线程的实现方式是NPTL、线程模型是 1x1,在创建线程时,既会创建一个用户级线程,也会创建一个内核级线程,你可以理解为内核级线程是工作的实体,而用户级线程是它的一个抽象。
最后,如果觉得本文对你有所帮助,麻烦点个赞!如果哪里讲得有问题,还麻烦指正,本人不胜感激!
参考
线程
LinuxThreads
POSIX线程
Native POSIX Thread Library
Is Pthread library actually a user thread solution?
Python threading module creates user space threads or kernel spece threads
linux查看进程线程数
pthread_create()函数:创建线程
python中的多线程和GIL锁