线程概念和Linux线程

线程概念和Linux线程

线程概念

在一个程序里的一个执行路线 (或执行流 )就叫做线程 (thread)。更准确的定义是:线程是 "进程内部的控制序列" 。

一切进程至少有一个执行路线线程是 CPU 调度的基本单位,是承担系统资源的基本实体。

至少,在 Linux、Windows 和 MacOS 等操作系统中基本符合。但一些嵌入式设备、Unix 古董不一定完全符合。

Linux的线程背景

一个进程,它要访问的绝大部分资源例如数据和代码,都要通过进程地址空间加页表的方式来做到,除了有些资源,比如文件操作,通信操作可能不通过地址空间。所以地址空间,在逻辑上相当于这个进程所能看到的资源的窗口。

每个进程它都有一个这样的资源窗口,每个进程通过地址空间加页表,就可以实现资源独立。通过把这个地址空间处理好,此时一个进程和其他进程,既能做到通信,也能保持独立性。

创建一个进程的成本比较高。因为它要加载各种数据,涉及到 I/O 操作;它要创建内核数据结构并初始化,要申请内存,空间不足时,还要向操作系统去做申请,操作系统可能会使用内存置换算法给进程腾出空间。而且有的代码就几十行,而有的代码动辄千行,万行,但所有的代码都用一个同样大小的进程资源,有很多资源在无形中被浪费掉。

创建一个进程最终目的都是为了执行代码,让计算机给我们做事。创建很多进程也是如此,于是历史上的那些大佬们就想着能不能优化成本。

Linux 是这样做的:创建一个 "进程" ,这个 "进程 " 只给它创建一个 PCB 对象 ,让多个这样的 "进程" 的 PCB 指向一个 4GB 的进程地址空间 ,这在操作上也只是多个指针指向同一块地址。这些 "进程" 共用同一个资源窗口,然后通过提前设计好的规则,哪些资源是所有 "进程" 共享,哪些资源划分给哪个 "进程" 。当 CPU 去执行时,它只会执行一个 "进程" 的一部分代码,访问该进程的一部分数据,执行任务的一部分。这样的轻量级进程 便称之为线程

所以 Linux 没有真正的线程只有轻量级进程的概念,Linux 只会提供轻量级进程创建的系统调用,不会直接提供线程创建的接口。

这样做也有好处:

  • 同一个地址空间关联的线程,访问的是同一个地址空间,所以注定了很多资源是可以实现共享的,而且很容易实现。比起实现进程间通信,操作上会方便不少。
  • 没有创建新的数据结构和算法,实现简单 。简单的东西意味着可靠 ,使代码的健壮性良好减少了程序员的维护成本

健壮性 (鲁棒性Robustness ) 是一个系统、程序或算法在面对异常输入恶劣环境自身部分故障 时,依然能够保持正常运行不崩溃不产生灾难性后果能力。简单来说就是 "抗揍" 和 "顶住压力" 的能力。

但问题总是伴随着问题的解决而诞生。线程比进程更轻量级的同时,带来的便是数量上的膨胀 。对线程的管理也增加了一个概念:线程控制块(thread control block,即 TCB)。当初设计 Linux 内核的大佬们,第一原则肯定是找能复用的代码,于是就尝试用 PCB 代替 TCB,因为二者的属性有很多相似的地方,很多字段都可以复用。这样创建线程时就创建 PCB 即可。

当然这只是 Linux 的设计方案。和 C 语言一样,有人制定一个标准,其他人就按照标准自己去实现,只要符合标准说的特征和要求就行。

所以很多操作系统的教材,不会告诉线程怎么去实现,而是告诉线程的特征。这个特征要求放在几乎所有的操作系统都是生效的。

所以与其说是教材,不如说是指导文档。对编程语言基础薄弱的同学,阅读本身就是挑战。

也不是所有的操作系统都和 Linux 一样,一个 task_struct 就能走天下,也有独立设计 TCB 或类似概念的,例如 Windows。

Linux的线程

所以 Linux 中,线程是比进程更加轻量化的一种执行流 。也有操作系统原理的教材会这样描述:线程 是在进程内部执行 的一种执行流。线程在进程内部运行,本质是在进程地址空间内运行。

在 Linux 系统, CPU 眼中看到的 PCB 都要比传统的进程更加轻量化。因为 CPU 不必区分当前的 PCB 是进程还是线程,只需要根据 PCB 的信息执行指令即可。

透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

线程优缺点

简单看看就行。用多了自然就记住了。

线程有如下优点:

  • 创建 一个新线程的代价 要比创建一个新进程得多。

  • 与进程之间的切换相比,线程之间的切换 需要操作系统做的工作少很多

  • 线程占用的资源 要比进程很多。

  • 充分利用多处理器 的可并行数量。对于多核心 CPU 来说,每个核心都有一套独立的寄存器 用于进行程序处理,因此可以同时将多个执行流 的信息加载到不同核心并行运行,充分利用 CPU 资源提高处理效率。

  • 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务

  • 比进程更适配计算密集型应用 (对系统某种或某几种资源有极高需求的应用程序),为了能在多处理器系统上运行,将计算分解到多个线程中实现。

  • 比进程更适配 I/O 密集型应用 ,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。I/O 密集型应用 是指一类程序,这类程序的特点是大部分时间花在等待 I/O 操作完成上,例如磁盘读写、网络请求、数据库查询、用户输入等,而非 CPU 计算。其特点是CPU 利用率低,等待时间长

但线程也有缺点:

  • 性能损失 。一个很少被外部事件阻塞计算密集型线程 往往无法与其他线程共享同一个处理器 。如果计算密集型线程的数量比可用的处理器 多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

  • 健壮性降低。编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。

  • 缺乏访问控制。进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。

  • 编程难度提高。编写与调试一个多线程程序比单线程程序困难得多。编程难度一降低,编程语言很容易被接受,受欢迎程度也会增加,例如 Python 。

  • 线程异常即进程异常。单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

合理的使用多线程,能提高CPU密集型程序的执行效率,能提高IO密集型程序的用户体验。例如生活中开发人员一边写代码一边下载开发工具,就是多线程运行的一种表现。

Linux进程VS线程

进程资源分配的基本单位线程调度的基本单位

在学习线程之前,Linux 的进程实际上是一个执行流独享整个 4GB 的内存空间,以后就是多个执行流瓜分整个 4GB 的内存空间。

但无论如何,一个进程能申请的资源是有限制的,所有线程只能瓜分这个进程申请到的资源。以时间片为例, CPU 处理当前进程,分配的时间片也会相对公平地分给所有线程,每个线程运行完后立刻切换另一个线程。

某个用户,他通过频繁创建线程来达到让自己不断获得时间片的目的,这对于其他进程来说也是不公平的。所以就设计了线程也要瓜分时间片的机制。

线程共享进程数据,但也拥有自己的一部分数据:

  • 线程 ID
  • 寄存器集合 。包括程序计数器(PC)和指令指针寄存器、通用寄存器、栈指针(SP)、帧指针(FP)等,线程切换时这些寄存器会恢复成对应线程的上下文数据
  • 独立的栈结构。每个线程拥有独立的调用栈,用于存储局部变量、函数参数、返回地址等。栈的大小可以在创建线程时指定,通常比进程的主栈小。
  • 线程局部存储 (Thread-Local Storage,TLS)。TLS 是一种机制,它让每个线程拥有某个变量的独立副本,而不是与同进程内的其他线程共享该变量。在各个编程语言都有实现该机制的方式。
  • errno
  • 信号屏蔽字
  • 调度优先级
  • 资源使用统计。如线程的CPU时间、内存使用等。

这些在后续的代码实现上会逐一体现。

简单使用线程

线程的概念是 C 语言时期就设计好的。但这个线程库在实际使用时存在很多问题。这个问题在 C++ 的线程库会提及。

Linux 没有真正的线程,只有轻量级进程的概念,Linux 只会提供轻量级进程创建的系统调用,不会直接提供线程创建的接口。

但主流教材都只知道线程,使得很多眼神清澈的大学生只知道线程,所以必须得在操作系统和用户之间封装一层软件层。用户他以为自己调用这些接口创建线程,但是在 Linux 内核里对应的就是一个 lwp。这个库被称之为 pthread 的原生线程库。

它本质上是一种软件分层,很容易实现结构。甚至上层的版本再怎么更新也并不影响内核。任何一款 Linux 系统都必须带这个库。

这里简单使用 C 语言的线程库创建线程。

pthread_create 线程创建

首先是创建线程。参考 man 3 pthread_create

cpp 复制代码
#include <pthread.h>
int pthread_create(pthread_t *thread, 
                   const pthread_attr_t *attr,
                   void *(*start_routine) (void *), 
                   void *arg);

thread:这个指针指向一个虚拟内存单元 (例如局部变量 ),成功的 pthread_create() 调用将新线程的 ID 存储在 thread 指向的缓冲区中;该标识符用于在后续调用其他 pthreads 函数时引用该线程。该内存单元的地址 即为新创建线程的线程 ID

attrattr 参数指向一个 pthread_attr_t 结构,其内容在线程创建时用于确定新线程的属性 ;该结构使用 pthread_attr_init 及相关函数初始化。如果 attrNULL,则使用默认属性创建线程。

start_routine函数指针,指向一个独立的函数。

arg:传递给 start_routine 的参数,类似于 main 的命令函参数 args ,但因为是 void* ,所以可以上传一个结构体对象,用于描述更多的信息。

线程需要单独占据一个子程序,而父、子进程共用同一代码块,根据 PID 选择自己的代码块。

测试代码如下,两个线程都会各自进行死循环。

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>

using std::cout;
using std::string;

void *newThread(void *arg) {
    string threadname = (char *)(arg);
    while (1) {
        cout << threadname << ", 进程" << getpid() << "\n";
        sleep(1);
    }
}

int main() {
    // typedef unsigned long int pthread_t;
    pthread_t tid; // 线程ID
    pthread_create(&tid, nullptr, newThread, (void *)"chile thread");
    while (1) {
        cout << "main thread, 进程" << getpid() << "\n";
        sleep(1);
    }
    return 0;
}

C 语言使用 Linux 当中的线程,需要在链接形成可执行程序时,引入一个叫做 pthread动态库 。这里用了 C++11 的关键字 nullptr ,所以标准对齐。

Linux 的线程是通过库来实现的,这个库帮用户进行从用户空间和内核之间的轻量级进程进行之间做适配,让用户既遵守操作系统的线程的概念和操作,又能够满足适配用户的 Linux 上的多线程。

这个执行流和信号的处理方式不同,信号的处理方式是完全隔离主执行流,去执行处理函数,当然线程也能做到这一点。

查看线程需要使用 ps 命令的 -L 选项。且线程还有 LWP (Light Weight Process)这个属性字段,或者说线程 ID ,操作系统内核通过 LWP 标识 2 个线程。

bash 复制代码
# 界面1
[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11 -lpthread # 带线程的代码,需要链接指定的动态库
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
main thread, 进程4502 # 两个线程是同一进程内的
chile thread, 进程4502 # 两个线程是同一进程内的
^C
[Bjarne@VM-8-8-centos cppTest]$ 
# 界面2
[Bjarne@VM-8-8-centos cppTest]$ while :; do ps -aL|head -1;ps -aL|grep a.exe|grep -v grep;sleep 1;done # 使用ps -aL查询线程
  PID   LWP TTY          TIME CMD
  # 省略重复信息
  PID   LWP TTY          TIME CMD
 4502  4502 pts/0    00:00:00 a.exe
 4502  4503 pts/0    00:00:00 a.exe
 # 省略重复信息
  PID   LWP TTY          TIME CMD
^C
[Bjarne@VM-8-8-centos cppTest]$ 

真实的操作系统调度时 "看" 到是 LWP 。这里的函数 newThread 因为功能很少,所以被重入也不影响,其他线程也能调用它。

进程的多个线程共享

同一个进程申请的线程同一地址空间都是所有线程共享的 。如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量各线程都可以访问,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式 ( SIG_ IGN、SIG_ DFL或者自定义的信号处理函数 ) 。
  • 当前工作目录
  • 用户 id id
  • 环境变量等。

在使用共享内存时就已经出现了互斥的问题。这个问题在多线程这里必然存在,这也是多线程的学习重点之一,详细后续介绍。

进程和线程的关系如下图:

这里再改进代码,新增一个全局变量:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>

using std::cout;
using std::string;

int cnt = 20;

void *newThread(void *arg) {
    string threadname = (char *)(arg);
    while (1) {
        cout << threadname << ", 进程" << getpid() << "\n";
        --cnt;
        sleep(1);
    }
}

int main() {
    // typedef unsigned long int pthread_t;
    pthread_t tid; // 线程ID
    pthread_create(&tid, nullptr, newThread, (void *)"chile thread");
    while (1) {
        cout << "main thread, 进程" << getpid() << "\n";
        cout << cnt << "\n";
        sleep(1);
    }
    return 0;
}

交互界面可看到 cnt 被子线程更改,主线程也能看到。

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g  -lpthread
./a.exe
main thread, 进程6698
20 # cnt
chile thread, 进程6698
main thread, 进程6698
19 # cnt被改
chile thread, 进程6698
main thread, 进程6698
18
chile thread, 进程6698
^Cmake: *** [a.exe] Interrupt

[Bjarne@VM-8-8-centos cppTest]$ 

局部性原理

线程切换 的时候在用户态实现 ,不用切换整个 CPU 内部的所有的寄存器,而只需要切换少量寄存器内的数据,就能完成线程之间的一个切换。而进程间切换是需要把 CPU 内的所有的寄存器内的数据全部都换掉。

所以线程的调度成本更低,原因主要是因为线程间切换保存的上下文数据更少。但这并不是线程切换更轻量化的主要矛盾。

这里查看 CPU 内的信息。

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ cat /proc/cpuinfo|grep cache
cache size      : 28160 KB # 主要查看这个缓存
cache_alignment : 64
cache size      : 28160 KB
cache_alignment : 64
[Bjarne@VM-8-8-centos cppTest]$ 

详细见计算机组成原理。

CPU 内还集成了一个存储空间,这个存储空间叫做硬件级别的 catch。

一旦 CPU 正在访问某一条代码 或者是某一行数据 ,会有较大的概率访问 这一行代码附近的代码 ,一般把这种特性叫做局部性原理 。局部性原理不是人为设计的,而是一个客观事实 ,或者说是统计规律

从概率上讲,CPU 接下来有很大可能会访问当前执行代码附近的代码。正是因为有局部性原理的存在,才从磁盘加载的内存,到内存池,提供了预加载机制,或者说给预加载机制提供理论基础

可执行程序,如果体积太大了,可以把这个可执行程序的一部分加载到物理内存,再执行下一步。代码也不例外。

但预加载机制可能出错,可能预加载的资源并不是用户想要的。但根据局部性原理,我出错的概率很低,几乎可以忽略不计。

有了这样的局部性原理,就可以并不太影响效率的情况下,满足用户加载一个较大的程序。

重新理解轻量化

根据局部性原理 ,CPU 可以将一部分高频访问的代码和数据预先加载 到 cache 缓存中。之后 CPU 访问代码时可以减少外部读取的次数。保存在 cache 缓存中的访问频率高、对业务和应用至关重要的数据叫做热数据。

缓存命中 指系统在处理重复或高度相似的输入 时,能够复用之前计算结果,从而降低计算成本和响应延迟。如果热数据处理完毕,此时缓存不命中,就让 cathe 内的数据失效,重新加载新的数据。

就像 C/C++ 会自己建立一个缓冲区一样,因为访问输入、输出设备的速度比较慢(在 CPU 看来),所以先屯一会,再统一发送,效率反而更高。

此外,可执行程序本身没有被全部加载,意味着页表也不需要全部映射,而是按需建立映射。

现在回答如何理解轻量化:

因为线程在 Linux轻量级进程 ,所以概率上即便切换了线程 ,CPU 有 cache 缓存的存在 ,里面的代码和数据也很可能会被立即访问寄存器内的数据也不需要全部替换

传统进程切换 时,每一次通过访问内存 来把代码加载到 CPU 当中,都要走总线 ,然后让内存进行寻址,找到 对应的代码指令集 ,这个过程的效率很低

且若是切换了进程 ,则整个 cache 缓存 很可能一下子就失效 了,需要将另一个进程内的数据加载进 cache 缓存 ,且寄存器内的数据全部都要换掉

多线程代码健壮性

创建多线程和创建多进程类似,只需要在主线程中多次调用 pthread_create 接口即可。

这个代码给 4 号线程弄了一个野指针,验证在一个线程崩溃的情况下,其他线程是否会和之前分析的一样是否会崩溃。

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
#include <vector>

using std::cout;
using std::string;
using std::to_string;
using std ::vector;
struct Thread_info {
    string thread_name;
    uint64_t tm;
    Thread_info(string &st, uint64_t tm) : thread_name(st), tm(tm) {}
};

void *threadRountine(void *args) {
    Thread_info &ti = *(Thread_info *)(args);
    while (1) {
        cout << ti.thread_name << ' ' << ti.tm << ", 所属进程" << getpid()
             << "\n";
        if (ti.thread_name == "thread4") {
            cout << "这里故意触发异常,验证健壮性降低的问题\n";
            int *p = 0;
            *p = 100;
        }
        sleep(1);
    }
}

int main() {
    vector<pthread_t> pths;
    string st;
    for (int i = 0; i < 5; i++) {
        // 线程名
        st = "thread" + to_string(i + 1);
        // 线程id
        pthread_t tid = pthread_t();
        // 弄一个对象作为线程参数
        Thread_info *tinfo = new Thread_info(st, time(nullptr));
        // 创建线程
        pthread_create(&tid, nullptr, threadRountine, tinfo);
        pths.push_back(i + 1);
        sleep(1);
    }
    while (1) {
        cout << "主线程\n";
        sleep(3);
    }
    return 0;
}

根据交互界面,结果与分析保持一致,一个线程崩溃时会导致整个进程崩溃

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g -lpthread
./a.exe
thread1 1772226940, 所属进程6229
thread1 1772226940, 所属进程6229
thread2 1772226941, 所属进程6229
thread1 1772226940, 所属进程6229
thread2 1772226941, 所属进程6229
thread3 1772226942, 所属进程6229
thread1 1772226940, 所属进程6229
thread4 1772226943, 所属进程6229
这里故意触发异常,验证健壮性降低的问题
make: *** [a.exe] Segmentation fault
[Bjarne@VM-8-8-centos cppTest]$ 

这里 thread1、thread2 的调度顺序混乱,说明一个进程内的若干个线程,哪一个在创建之后先被调度是不确定的。

pthread原生线程库

pthread 库是基于 POSIX 标准的线程操作相关的函数集合,也可以叫原生线程库,属于 NPTL 线程库的范畴。这个库是 C 语言的接口,很多编程语言是对这个库的接口进行了封装。

NPTL(Native POSIX Thread Library) 是 Linux 系统中用于实现 POSIX 线程标准的线程库。NPTL 于 Linux 内核 2.6 版本引入,并成为 GNU C 库的一部分。因此使用 NPTL,系统需要运行支持该库的内核版本(如 2.6 及以上)和相应的 GNU C 库版本。可通过命令 getconf GNU_LIBPTHREAD_VERSION 检查当前系统使用的线程库。

例如这里测试的系统属于旧版本。

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.17
[Bjarne@VM-8-8-centos cppTest]$ 

pthread 库简介

Linux 没有线程的概念,但为了和主流教材对接 ,就通过 pthread 库来解决,这个库在任何操作系统里都会提供,但只有类 Unix 操作系统(包括Linux)才将这个库作为操作系统的一部分,其他操作系统可能需要安装或链接移植库才能使用。

在 Linux 通过 pthread 库创建的线程内核级线程 ,因为这个线程通过系统调用 clone 实现,系统调用需要进程进入内核态访问内核数据。

此外还存在一个用户级线程 。在 Linux 1.x 以及更早的版本没有提供任何专门用于支持线程的系统调用,于是一些第三方库尝试实现 "伪线程" ,这种线程实际是在一个进程内通过 setjmp/lomgjmp 模拟实现,不需要经过内核 , 内核处理的从始至终都只有 1 个进程。这 2 个函数都可在 man 的 3 号手册查到,这里不做详细介绍。

但这种用户及线程存在缺陷:任何一个线程执行一个会产生阻塞的系统调用 (例如 read) ,会导致整个进程被挂起。并且当时的标准不完善,于是Xavier Leroy 在 1996 年左右开发了 LinuxThreads,它首次为 Linux 带来了可用的 pthread 接口。

LinuxThreads 不符合后来的 POSIX 标准 (getpid 返回不同、需要其他线程参与管理) ,于是被 Red Hat 的 Ingo Molnar 主导开发了 NPTL(Native POSIX Thread Library)替代,NPTL 随 Linux 2.6 内核一同发布。NPTL 也是基于 clone 系统调用,但巧妙利用了内核新增加的特性和改进的抽象,完美地实现了 POSIX 标准。

pthread 库是动态库。很多涉及多线程的进程都会链接这个库。代码调用 pthread 库内的接口也相当于在自身内部的进程地址空间内跳转。

pthread 库也会通过页表找到内存中的数据。

这里可通过 ldd 指令查看 pthread 库的链接位置。例如:

libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f2beb04d000)

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11 -lpthread
[Bjarne@VM-8-8-centos cppTest]$ ldd a.exe
        linux-vdso.so.1 =>  (0x00007fffe3dd2000)
        libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f2beb04d000) # pthread库的位置
        libstdc++.so.6 => /home/Bjarne/.VimForCpp/vim/bundle/YCM.so/el7.x86_64/libstdc++.so.6 (0x00007f2beaccc000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f2bea9ca000)
        libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f2bea7b4000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f2bea3e6000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f2beb269000)
[Bjarne@VM-8-8-centos cppTest]$ 

线程ID及进程地址空间布局

pthread 库还能对 Linux 内的线程进行管理。Linux 内注定会有很多线程,根据之前的经验需要对线程进行先描述再组织,于是需要有 struct tcb 类似的概念,这个概念就需要在 pthread 库里去实现, struct tcb 的很多属性也可以通过 LWP 来获取。

如下图所示,mmap 区域 (即内存映射区域,Memory Mapping Region) 可以认为是 pthread 动态库在进程中映射的位置。 {struct pthread,线程局部存储,线程栈} 就是行驶 struct tcb 功能的结构体,每个线程都会通过这个结构体进行描述,其中也包含线程的属性集 struct pthread 。多个线程时,会以这个结构体的数组的形式被管理。

mmap 区域专门用于存放通过 mmap() 系统调用建立的内存映射。这些映射包括:

  • 文件映射:将一个文件的内容直接映射到进程的虚拟内存。比如,动态链接库(.so 文件)就是这样加载进来的。
  • 匿名映射:不关联任何文件,纯粹的内存分配。malloc 在申请大块内存时,底层常常用 mmap 来分配。
  • 共享内存映射:多个进程可以通过映射同一个文件或匿名区域,实现高效通信(即 System V 共享内存的 POSIX 替代方案)。

尽管 pthread 库是把系统中相关的系统调用,特别是创建轻量级进程的系统调用封装起来,但它也可以为每一个线程封装一个简单的 " tcp " ,虽然不一定能被找到,但struct tcb 的概念一定存在。

最终构建出来线程控制块 tcb ,和系统中的 LWP 即它所在的 PCB 一一对应。

线程的退出结果会保存到这个控制块中。线程想要获取其他已退出的线程的退出结果,只需要去库中通过 pthread_join 函数获取即可。这些函数会在线程控制中进行介绍。

所以线程 ID 的本质是线程属性集合在进程地址空间中的地址 。这个 ID 和 ps -aL 查到的线程 LWP 并不匹配,它通过pthread_ create 函数产生,可用于唯一表示该线程。

系统调用clone

每个线程都要有 一个属于自己的独立的栈结构上下文数据,pthread 库会去维护这些信息,这就相当于线程的一种属性。

但进程地址空间的栈只有 1 个,这个栈只能给主线程(main 函数所在的线程)使用。所以新线程在堆区去申请一块空间 ,然后通过地址来标识堆空间 ,并将这块空间作为新线程的栈

这里有一个系统调用 clone ,专门用来创建轻量级进程, fork 底层也封装了它。

cpp 复制代码
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack,
          int flags, void *arg, ...
          /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

clonefork 一样也会创建进程 ,但和 fork 不同的是,clone 允许子进程与调用进程共享其执行上下文的部分内容,比如内存空间、文件描述符表和信号处理函数表。clone 的主要用途是实现线程,为 Linux 独有,若代码中出现 clone 的使用,则不应该将代码移植到其他操作系统。

参数

  • fn:上传一个 int (*)(void*) 类型的函数指针 ,参数类型是 void* ,返回值是 int 。这个指针指向一个函数。

  • child_stack:用户上传的栈空间的地址

  • flags:简单理解来说就是可以选择使 clone 创建轻量级进程,还是完整的进程。这个参数的内容较多,详细见 man 2 clone

返回值成功时 ,在调用者的执行线程中返回子进程的线程 ID失败时 ,在调用者的上下文中返回 -1不会创建子进程 ,并适当设置 errno

用户以为自己调用了pthread_create,可实际上 pthread 在底层就是使用 clone 来创建 ,把用户提供的方法传进来,并且这个库当中会调用 mallocnew 底层也是 malloc)一段堆空间。然后把堆空间的地址进行存储,充当这个线程的栈。所以新线程的栈在库中维护,库一般是被加载到进程地址空间的共享区,这个库本身就是在用户空间,由用户提供。

这个操作并不是首次, C 语言的很多函数内部都会自定义一个缓冲区,就是在函数内部维护一个空间。

退出线程时只要把目标线程(轻量级进程)直接终止掉,然后把它在库当中给这个线程所维护的这个栈结构释放即可。

所以 pthread_create用户态库函数 ,它封装了 clone() 系统调用,在内核中创建一个 LWP 。因此,用户态叫"线程",内核态叫"轻量级进程",二者一一对应,只是观察角度不同。

相关推荐
clear sky .3 小时前
[freertos源码阅读]task.c任务篇
c语言·开发语言
lingran__3 小时前
C++ 高阶数据结构:红黑树万字详解|完整原理推导 + 插入实现 + 完整性校验【STL 底层】
数据结构·c++·面试·红黑树·二叉搜索树·平衡二叉树·stl底层
东东最爱敲键盘4 小时前
day9.C++多态之虚函数
开发语言·c++
酷可达拉斯4 小时前
自动化运维-从零开始编写ansible剧本(二)
linux·运维·服务器·centos·自动化
Darkwanderor4 小时前
Linux线程控制
linux·c语言·c++
Brilliantwxx4 小时前
【Linux】 开发|Git 版本控制与 gdb/cgdb 调试
linux·笔记·git
allforgood4 小时前
管理 SELinux 安全性
linux·运维·网络
Darkwanderor4 小时前
多线程的互斥问题和互斥锁解决互斥问题
linux·c语言·c++
键盘会跳舞4 小时前
C++:std::pair 源码级深度剖析 —— 关联容器的基石
开发语言·c++·pair·关联容器