探讨内核级线程和用户级线程

1,313 阅读4分钟

铺垫

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

在面试过程中,谈谈进程、线程和协程的区别几乎是一个必考点,如果深入的话,也会让我们讲讲内核级线程和用户级线程的区别。

如果应付面试的话,这个问题也有它的一套"标准答案",比如用户线程由用户实现,内核线程由内核实现等,但就个人而言,这样的回答未免太过抽象。

对于每个程序员而言,创建进程、创建线程算是家常便饭了,调用起来也不麻烦,因为大多数语言都帮我们封装好了,那么,不同的语言创建的线程到底是用户级线程,还是内核级线程呢?

而这,就是本次探讨的主题了,由于本人知识领域有限,所以本文只会从Linux、Python、Golang这三方面进行探讨,如有错漏,希望能得到你的指正!!

应付的"标准答案"

用户级线程内核级线程
由用户实现由内核(操作系统)实现
内核不知道它的存在内核知道它的存在
实现起来比较简单实现起来比较复杂
上下文切换花费时间少上下文花费时间多
一个线程阻塞,其他线程也会阻塞一个线程阻塞,其他线程并不会阻塞
线程之间互相依赖线程之间互相独立

Linux线程

在Linux中,线程是如何实现的呢?通过查阅维基百科,我发现了几个关键词:Linux ThreadPOSIX 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数量: 20211107223241.png

再查看其在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锁