【Linux】多线程01 --- 理解线程 线程控制及封装

作者:阿润菜菜
专栏:Linux系统编程


目录

  • 一、线程概念 -- 理解线程与进程的区别和联系
    • 1. 再次理解用户级页表和进程地址空间
    • 2.理解Linux的轻量级进程
    • 3. 线程的属性
    • 4.线程的优点和缺点及应用
  • 二、线程的控制 --- 学学接口使用
    • 1. 线程的创建
    • 2.线程的终止和等待
  • 三、 线程的封装 --- 再次理解线程
    • 1. 用户级线程tid究竟是什么?(映射段中线程库内的TCB的起始地址)
    • 2.线程的局部存储(介于全局和局部变量之间的,线程特有的一种存储方案)
      • 为什么需要线程的局部存储
      • 如何使用线程的局部存储
      • Linux中线程的局部存储的实现
    • 3.封装一个线程类 --- 面向对象思想
      • 线程类的设计
      • 线程类的实现
      • 线程类的使用


一、线程概念 – 理解线程与进程的区别和联系

在Linux中其实没有真正线程的概念,在Linux中线程的概念其实就是进程内部的一个执行流。在宏观层面上理解,线程是执行流这句话放在任何一个OS上都没错,但落实到具体操作系统上,不同的OS多线程实现策略是不一样的,例如Windows底层就有真正的线程实现(所以程序开多了会卡),而Linux的线程只是一种轻量级进程,下面我们落实到Linux系统的多线程实现策略上,来一起学习一下!

1. 再次理解用户级页表和进程地址空间

在我们写出下面这两行代码时,为什么编译器会报错呢?但是只写第一行编译器会报个Warning,可以编译通过,有没有想过?

char *ptr = "hello world!";
*ptr = "Hello";

在语言级别,我们给出的解释是:

  1. 第一行代码能编过的原因是权限缩小,虽然ptr是可读可写的权限,但在指向常量字符串"hello world"(堆区)之后,ptr的权限就变为了只读,所以如果仅仅修改一下权限,g++并不会报错,只是报个warning罢了,
  2. 但当解引用ptr,将ptr指向的内容修改为"H"字符串后,编译器就会报错了,因为我们说ptr的权限是只读,因为常量字符串是不可修改的,你现在进行了ptr指向内容的修改,编译器则一定会报错!

现在我们来看看内核角度,为什么ptr指针指向一修改,编译器就能报错呢?进程就会退出呢?进程怎么知道的?怎么被终止的?
【Linux】多线程01 --- 理解线程 线程控制及封装_第1张图片

实际上,页表帮我们做的事情不止虚拟地址到物理地址空间的映射转换这么简单!他还会记录虚拟地址映射到物理地址的权限,如读写及执行权限,用户层/内核层权限,虚拟地址是否有效命中到对应的物理地址上等等!
所以上面解引用指针ptr时,底层经过用户级页表映射,MMU会发现ptr这个虚拟地址对应的权限是R权限,那就是只读不能被修改,此时进程如果执意要进行修改,那就会导致硬件MMU直接报错,操作系统知晓MMU报错后,就会给对应的进程发11号信号(Segmentation fault),当进程在合适的时候就会去处理这个信号,处理信号的默认动作就是终止当前进程!

如何理解用户级页表和进程地址空间呢?-

从功能角度来谈,进程地址空间就是进程能够看到的资源的窗口,因为进程所占用的系统资源都是分配在物理内存上的,想要访问这些系统资源都需要地址空间来作为中间件去访问。
页表真正决定了进程实际拥有资源的情况,进程对某个资源具有什么权限?访问此资源需要的进程级别?一个不属于当前进程的虚拟地址,进程能否通过这个地址访问对应物理内存上的资源呢?这些问题都需要依靠页表来解决!所以进程对资源的真正掌握情况是通过页表来实现的!
那该如何对进程的资源进行划分呢? 合理的对地址空间+页表进行资源划分,我们就可以对进程的所有资源进行分类!

那虚拟地址到底是如何转换到物理地址的?
实际上,OS作为软硬件资源的管理者,实施的还是那套经典方法:“先组织,在管理”。
我们知道Linux中虚拟地址到物理地址的转换是通过MMU(内存管理单元)和页表(page table)来实现的。而页表是OS的一种数据结构,用于存储虚拟地址和物理地址之间的映射关系。Linux使用多级页表,通常有四级:页全局目录(PGD)、页上级目录(PUD)、页中间目录(PMD)和页表(PT)。每一级页表都有一个索引,用于定位下一级页表的地址。最后一级页表中的表项包含了物理地址的基地址,再加上虚拟地址的偏移量,就得到了最终的物理地址。

这个过程可以用以下图来表示:
【Linux】多线程01 --- 理解线程 线程控制及封装_第2张图片
这样页表最多占用4MB ,而且可能有的页表不会加载

具体参考: Linux内核学习3——虚拟地址转换成物理地址

2.理解Linux的轻量级进程

1.我们可以将进程的资源划分给不同的线程,让线程来执行某些代码块儿,而线程就是进程内部的一个执行流。那么此时我们就可以通过地址空间+页表的方式将进程的资源划分给每一个线程,那么线程的执行粒度一定比之前的进程更细!

2.Linux中并没有专门为线程创建真正的数据结构来管理,而是直接复用进程的PCB当作线程的描述结构体,用PCB来当作Linux系统内部的"线程"。 这么做的好处是什么呢?如果要创建真正的线程结构体,那就需要对其进行维护,需要和进程构建好关系,每个线程还需要和地址空间进行关联,CPU调度进程和调度线程还不一样,操作系统要对内核中大量的进程和线程做管理,这样维护的成本太高了!不利于系统的稳定性和健壮性,所以直接复用PCB是一个很好的选择,维护起来的成本很低,因为直接复用原来的数据结构就可以实现线程。所以这也是linux系统既稳定又高效,成为世界上各大互联网公司服务器系统选择的原因

3.所以Linux内核是怎么设计线程的? Linux用进程的PCB来模拟线程,是完全属于自己实现的一套方案!站在CPU的角度来看,每一个PCB,都可以称之为轻量级进程,因为它只需要PCB即可,而进程承担分配的资源更多,量级更重!
Linux线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体!
进程用来整体向操作系统申请资源,线程负责向进程伸手要资源。如果线程向操作系统申请资源,实质上也是进程在向操作系统要资源,因为线程在进程内部运行,是进程内部的一部分!
用pcb模拟线程的好处是维护成本大大降低,系统变得更加可靠、高效、稳定。windows操作系统是给老百姓用的,可用性必须要高。linux是给程序用的,必须要可靠稳定高效。所以由于需求的不同,产生了不同实现方案的操作系统。

我们说Linux中没有线程只有轻量级进程,怎么证明呢?
因为Linux内核并没有单独为线程设计数据结构,而是复用了进程的PCB。所以Linux无法直接提供创建线程的系统调用接口,只能提供创建轻量级进程的接口。轻量级进程是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联。

Linux为了让用户能够得到他想要的线程,只能通过原生线程库来给用户他想要的,所以在用户和内核之间有一个软件层,这个软件层负责给程序员创建出程序员想要的线程。除这个原生线程库会创建出线程结构体外,但同时linux内核中会通过一个叫clone的系统调用来对应的创建出一个轻量级进程,所以我们称这个库是用户级线程库,因为linux是没有真正意义上的线程的,无法给用户创建线程,只能创建对应的PCB,也就是轻量级进程!
我们要证明Linux中没有线程只有轻量级进程,可以通过查看 /proc目录下的进程信息,或者使用ps -eLf 命令查看进程和线程的标识符。所以Linux下线程实际上是封装了原生线程库的!
在这里插入图片描述

而且如果在编译时不带-lpthread选项,可以看到g++报错pthread_create()函数未定义,其实就是因为链接器链接不上具体的动态库,此时就可以看出来linux内核中并没有真正意义的线程,他无法提供创建线程的接口,而只能通过第三方库libpthread.so或libpthread.a来提供创建线程的接口。

那线程切换的工作谁来做OS在这里插入图片描述
如何理解线程之间切换不需要切换地址空间、页表、cache缓存?
因为线程是共享这些资源的,所以当一个进程中两个线程进行切换时,它们不需要改变地址空间、页表、cache缓存,这样可以减少切换的开销和提高效率!另外我们要知道,虚拟地址空间和页表是由操作系统管理的,而cache缓存是由CPU管理的。如果切换的线程在不同的CPU核心上运行,那么chche缓存可能失效,因为不同的核心有不同的cache缓存。
如何理解cache缓存? 01
如何理解cache缓存? 02

3. 线程的属性

请简述什么是LWP?
LWP是轻量级进程,在Linux下进程是资源分配的基本单位,线程是cpu调度的基本单位,而线程使用进程pcb描述实现,并且同一个进程中的所有pcb共用同一个虚拟地址空间,因此相较于传统进程更加的轻量化。
【Linux】多线程01 --- 理解线程 线程控制及封装_第3张图片

我们可以通过ps -aL 命令就可以看到正在运行的线程有哪些,可以看到有两个标识符,一个是PID,一个是LWP(light weight process),所以CPU在调度那么多的PCB时,其实是以LWP作为每个PCB的标识符,以此来区分进程中的多个轻量级进程(线程)。
主线程的PID和LWP是相同的,所以从CPU调度的角度来看,如果进程内只有一个执行流,那么LWP和PID标识符对于CPU来说都是等价的,但当进程内有多个执行流时,CPU是以LWP作为标识符来调度线程,而不是以PID来进行调度。
【Linux】多线程01 --- 理解线程 线程控制及封装_第4张图片

线程一旦被创建,几乎所有的资源都是共享的!
因为一个进程中的所有线程都共享进程地址空间,地址空间中的栈,堆,已初始化/未初始化数据段,代码段,这些区域中的资源都是共享的,每个线程都可以看到,那么任意一个线程就都可以去访问这些资源了!
所以如果线程想要通信,那成本是要比进程间通信低很多的,由于进程具有独立性,所以进程间通信的前提是让不同的进程能够看到同一份资源,看到同一份资源的成本就很大,例如之前我们所学的,通过创建管道或共享内存的方式来让进程先能够看到同一份资源,然后才能继续向下谈通信的话题。但是今天,对于线程来说完全不需要考虑看到同一份资源这个问题,因为一个进程内的所有线程天然的可以共享进程地址空间,你可以直接定义一个全局缓冲区,一个线程往里写,另一个线程立马就可以从缓冲区中看到另一个线程写的信息,所以线程通信的成本非常低!
【Linux】多线程01 --- 理解线程 线程控制及封装_第5张图片
多线程程序中,一个线程崩溃,整个进程都绷了 为什么?线程是进程的执行分支,线程干了就是进程干了!(系统角度)【Linux】多线程01 --- 理解线程 线程控制及封装_第6张图片
线程不需要考虑异常处理情况,进程会处理

那什么资源是线程应该私有的呢?

a.线程PCB的属性,例如线程id,线程调度优先级,线程状态等等…(这个回答不回答不重要,重要的是回答出下面那两点)
b.线程在被CPU调度时,也是需要进行切换的,所以,线程的上下文结构也必须是线程的私有资源。(这点可以体现出我们知道线程是动态的,CPU调度线程会轮换,线程会被切换上来也会被切换下去)
c.每个线程都会执行自己的线程函数,就是那个start_routine函数指针所指向的函数,所以每个线程都有自己的私有栈结构。
线程私有制最重要的两个概念: 一组寄存器 独立的栈结构

那我们如何理解线程拥有自己独立的栈结构?
【Linux】多线程01 --- 理解线程 线程控制及封装_第7张图片
通过地址打印可以发现线程库(管理线程,先描述在组织)对线程地址数据进行了组织封装

同样的在底层 :更改寄存器偏移量就能切换栈
【Linux】多线程01 --- 理解线程 线程控制及封装_第8张图片

【Linux】多线程01 --- 理解线程 线程控制及封装_第9张图片
Linux中轻量级进程之间可以共享进程的资源和环境,如代码段、数据段、堆、文件描述符、信号处理器、当前工作目录等。但是轻量级进程也有自己的私有数据,如寄存器组、栈空间、错误返回码、信号屏蔽字、优先级等

小细节:
为什么一个变量要取首地址?地址最低的地址 — 为了方便拿数据 — 寄存器偏移
【Linux】多线程01 --- 理解线程 线程控制及封装_第10张图片

4.线程的优点和缺点及应用

线程优点:
【Linux】多线程01 --- 理解线程 线程控制及封装_第11张图片

缺点:
【Linux】多线程01 --- 理解线程 线程控制及封装_第12张图片

线程的两种应用场景:【Linux】多线程01 --- 理解线程 线程控制及封装_第13张图片
那线程越多越好吗? 对计算密集型一般选择CPU核数多少个创建多少个进程/线程最合适
【Linux】多线程01 --- 理解线程 线程控制及封装_第14张图片

二、线程的控制 — 学学接口使用

这里我们只讲解线程的基本控制 — 线程的创建终止和等待

1. 线程的创建

要创建一个新的线程,可以使用pthread_create()函数,它需要四个参数:

  • 第一个参数是一个指向pthread_t类型的变量的指针,用来存储新创建的线程的标识符。
  • 第二个参数是一个指向pthread_attr_t类型的变量的指针,用来设置新创建的线程的属性,如优先级、栈大小等。如果为NULL,则使用默认属性。
  • 第三个参数是一个函数指针,指向新创建的线程要执行的函数。该函数必须返回void *类型,并接受一个void *类型的参数。
  • 第四个参数是一个void *类型的变量,作为第三个参数所指向函数的参数传递给新创建的线程。

如果成功创建了新线程,则pthread_create函数返回0,并将新线程的标识符存储在第一个参数所指向的变量中;否则返回错误码,并不修改第一个参数所指向的变量。

下面是一个简单的示例代码,创建了两个新线程,并分别打印出"Hello"和"World":

#include 
#include 

void *say_hello(void *arg) {
    printf("Hello\n");
    return NULL;
}

void *say_world(void *arg) {
    printf("World\n");
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    int ret1, ret2;

    ret1 = pthread_create(&tid1, NULL, say_hello, NULL);
    if (ret1 != 0) {
        perror("pthread_create");
        return -1;
    }

    ret2 = pthread_create(&tid2, NULL, say_world, NULL);
    if (ret2 != 0) {
        perror("pthread_create");
        return -1;
    }

    pthread_exit(NULL); // 主线程退出,但不终止其他线程
}

2.线程的终止和等待

要终止一个线程,有三种方法:

  • 第一种方法是让线程函数正常返回,返回值可以通过另一个线程调用pthread_join函数来获取。
  • 第二种方法是让线程函数调用pthread_exit函数,并传递一个void *类型的参数作为返回值。该函数会立即终止调用者所在的线程,并释放其占用的资源。返回值同样可以通过pthread_join函数来获取。
  • 第三种方法是让另一个线程调用pthread_cancel函数,并传递要终止的线程的标识符作为参数。该函数会向目标线程发送一个取消请求,但不一定会立即终止目标线程,因为目标线程可以设置自己对取消请求的响应方式。

为什么要有线程等待?pthread_join()
防止目标线程还没办完事就被结束释放了
我们写线程等待是为了让调用线程等待目标线程结束,然后获取它的返回值或者退出状态。如果不等待,那么目标线程可能还没有结束就被释放了,导致资源泄露或者不一致的结果。pthread_join()函数就是用来实现线程等待的。它需要传入一个线程标识符和一个指向void*的指针,如果目标线程正常退出,那么它的返回值会被存储在指针所指向的位置;如果目标线程被取消,那么指针所指向的位置会被设置为-1。pthread_join()函数只能等待一个可连接的线程,也就是说,目标线程没有被设置为分离状态。而且,同一个线程只能被等待一次,否则会导致未定义的行为。当pthread_join()函数成功返回时,目标线程就会被分离,不再占用系统资源。

下面是一个示例代码,主线程创建了两个子线程,并分别等待它们终止,并获取它们的返回值:

#include 
#include 

void *add(void *arg) {
    int a = 10;
    int b = 20;
    int c = a + b;
    printf("The sum is %d\n", c);
    pthread_exit((void *)&c); // 以c的地址作为返回值
}

void *sub(void *arg) {
    int a = 10;
    int b = 20;
    int c = a - b;
    printf("The difference is %d\n", c);
    return (void *)&c; // 以c的地址作为返回值
}

int main() {
    pthread_t tid1, tid2;
    int ret1, ret2;
    void *res1, *res2;

    ret1 = pthread_create(&tid1, NULL, add, NULL);
    if (ret1 != 0) {
        perror("pthread_create");
        return -1;
    }

    ret2 = pthread_create(&tid2, NULL, sub, NULL);
    if (ret2 != 0) {
        perror("pthread_create");
        return -1;
    }

    ret1 = pthread_join(tid1, &res1); // 等待第一个子线程结束,并获取其返回值
    if (ret1 != 0) {
        perror("pthread_join");
        return -1;
    }

    ret2 = pthread_join(tid2, &res2); // 等待第二个子线程结束,并获取其返回值
    if (ret2 != 0) {
        perror("pthread_join");
        return -1;
    }

    printf("The result of add is %d\n", *(int *)res1); // 打印第一个子线程返回值
    printf("The result of sub is %d\n", *(int *)res2); // 打印第二个子线程返回值

    return 0;
}

细致讲解一下 pthread_join()函数
有些人可能觉得join的第二个参数不太好理解,所以这里在细说一下这个部分,以前如果我们想拿到一个函数中的多个返回值,但由于函数的返回值只能有一个,所以为了拿到多个返回值,我们都是在调用函数之前,定义出想要拿到的返回值的类型的变量,然后把这个变量的地址传给需要调用的函数,这样的函数参数我们称为输出型参数,然后在函数内部会通过解引用输出型参数的方式,将函数内部的某个需要返回给外部的值拷贝到解引用后的参数里面,那其实就是修改了我们函数外部定义的变量的值。
这里不好理解的原因其实是因为二级指针,我们想要拿到的线程函数的返回值是一个指针,不再是一个变量,所以在调用join的时候,仅仅传一级指针是不够的,我们需要传一级指针变量的地址,让join内部能解引用一级指针变量的地址,拿到外面的一级指针内容并对其修改。
【Linux】多线程01 --- 理解线程 线程控制及封装_第15张图片

三、 线程的封装 — 再次理解线程

线程是进程内的一个执行单元,它共享进程的资源,但有自己的栈、寄存器、信号屏蔽字等。线程之间可以通过共享内存、信号、信号量等方式进行通信和同步。线程相比进程,创建和销毁的开销更小,切换的代价也更低。

Linux中有两种线程模型:用户级线程和内核级线程。用户级线程是由用户程序或库实现的,不依赖于内核支持,因此具有更高的灵活性和效率,但也有一些缺点,如不能利用多核处理器,不能响应信号等。内核级线程是由内核提供的,每个线程都对应一个轻量级进程(LWP),因此可以享受内核的调度和管理,但也要付出更多的系统开销。

Linux中最常用的线程库是POSIX线程库(pthread),它提供了一套标准的API来创建和控制线程。

1. 用户级线程tid究竟是什么?(映射段中线程库内的TCB的起始地址)

除线程库要在用户层创建一个描述线程的数据结构外,实际操作系统还会给用户层的TCB创建出来对应的轻量级进程内核数据结构,进行内核中轻量级进程的管理。
所以可以认为,线程是POSIX库中实现了一部分,操作系统中实现了一部分。每当我们创建一个线程时,库就要帮我们在用户层创建出对应的线程控制块TCB,来对库中的多个线程进行管理,同时操作系统还要在对应的创建出轻量级进程。
所以,Linux用户级线程 : 内核轻量级进程 = 1:1。用户关心的线程属性在用户级线程中,内核提供轻量级进程(线程)的调度!
内核中创建轻量级进程调用的接口就是clone,它可以帮助我们创建出linux认为的"线程"。

【Linux】多线程01 --- 理解线程 线程控制及封装_第16张图片
像之前所使用的join函数的第一个参数,也就是tid,他就是TCB的起始地址,也就是指向TCB结构体的指针,而线程函数的返回值实际会写到TCB结构体中的某一个字段,join函数需要tid这个地址,实际就会通过这个结构体指针从TCB中拿到表示线程函数返回值的那个字段的内容。然后将其写到join的第二个参数 void **retval里面
并且我们现在也能回头去理解一些东西了,例如为什么叫用户级线程库,当然是因为线程库会被映射到虚拟地址空间的映射段啊,而映射段不就是在用户空间吗?线程库的代码都是跑在用户空间的上的,所以线程库也叫用户级线程库。
【Linux】多线程01 --- 理解线程 线程控制及封装_第17张图片

2.线程的局部存储(介于全局和局部变量之间的,线程特有的一种存储方案)

Linux中线程的局部存储(thread-local storage,TLS)是一种分配不同线程独有的对象的机制,它们允许使用声明的名字来引用与当前线程相关联的实体。

为什么需要线程的局部存储

在多线程编程中,有三种类型的变量:

  • 全局变量:在程序的整个生命周期内存在,对所有线程可见,需要使用同步机制来避免竞争条件。
  • 局部变量:在函数调用时创建,函数返回时销毁,对当前线程可见,不需要使用同步机制。
  • 线程的局部存储:在线程创建时创建,线程结束时销毁,对当前线程可见,不需要使用同步机制。

线程的局部存储相当于介于全局变量和局部变量之间的一种存储方案,它可以实现以下目标:

  • 避免全局变量带来的同步开销和数据不一致问题。
  • 避免局部变量带来的重复创建和销毁开销和数据传递问题。
  • 提高数据访问的效率和安全性。

如何使用线程的局部存储

要使用线程的局部存储,只需要在变量声明时加上__thread、_Thread_local或者thread_local关键字,例如:

__thread int i; // GCC扩展
_Thread_local int j; // C11标准
thread_local int k; // C++11标准

这些关键字可以单独使用,也可以和extern或static一起使用,但不能和其他存储类别说明符一起使用。当和extern或static一起使用时,__thread、_Thread_local或者thread_local必须紧跟在其他存储类别说明符之后。

这些关键字可以应用于任何全局变量、文件作用域静态变量、函数作用域静态变量或者类的静态数据成员。它们不能应用于块作用域自动变量或者类的非静态数据成员。

当对一个线程的局部存储变量应用取地址运算符时,它会在运行时计算出当前线程实例该变量的地址,并返回该地址。这个地址可以被任何线程使用。当一个线程终止时,任何指向该线程中线程的局部存储变量的指针都会失效。

在C++中,如果一个线程的局部存储变量有初始化器,则必须是一个常量表达式。

Linux中线程的局部存储的实现

Linux中线程的局部存储的实现比较复杂(这篇文档详细解释了四种线程的局部存储寻址模型以及运行时期望如何工作),但基本思路是这样的:

  • 编译器会将一个含有__thread、_Thread_local或者thread_local关键字的变量放到一个特殊的.tdata段中,这个段包含了所有线程的局部存储变量。
  • 在运行时,每个线程都会创建一个新的数据段,并复制.tdata段中的数据到该段中,当线程切换时,该段也会自动切换。
  • 最终结果是__thread、_Thread_local或者thread_local变量和普通变量一样快,并且不占用额外的栈空间。

下面是一个示例代码,使用__thread关键字定义了一个全局变量和一个函数作用域静态变量,并打印出它们在不同线程中的地址:

#include 
#include 

__thread int global = 0; // 全局变量

void *foo(void *arg) {
    __thread static int local = 0; // 函数作用域静态变量
    printf("This is thread %d\n", *(int *)arg);
    printf("The address of global is %p\n", &global);
    printf("The address of local is %p\n", &local);
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    int ret1, ret2;
    int arg1 = 1, arg2 = 2;

    ret1 = pthread_create(&tid1, NULL, foo, &arg1);
    if (ret1 != 0) {
        perror("pthread_create");
        return -1;
    }

    ret2 = pthread_create(&tid2, NULL, foo, &arg2);
    if (ret2 != 0) {
        perror("pthread_create");
        return -1;
    }

    pthread_join(tid1, NULL); // 等待第一个子线程结束
    pthread_join(tid2, NULL); // 等待第二个子线程结束

    return 0;
}

输出结果可能是这样:

This is thread 1
The address of global is 0x7f9a6e8a6e80
The address of local is 0x7f9a6e8a6e84
This is thread 2
The address of global is 0x7f9a6e0a5e80
The address of local is 0x7f9a6e0a5e84

可以看到,在不同线程中,global和local变量有不同的地址,但相对位置是相同的。这说明每个线程都有自己独立的数据段,并且该段中包含了所有线程的局部存储变量。

3.封装一个线程类 — 面向对象思想

Linux中提供了一套标准的线程API,它允许我们创建并控制多个并发的执行流。但是,pthread库是基于C语言的,它只提供了一些函数和数据结构,而没有提供面向对象的抽象和封装。如果我们也想像C++11那样通过面向对象的方式来玩,我们可能需要自己封装一些接口,来实现一个线程类(thread class),使得我们可以更方便地创建和管理线程对象。

线程类的设计

我们首先要设计一个线程类的接口,即定义它的成员变量和成员函数。一个基本的线程类应该包含以下内容:

  • 一个pthread_t类型的变量,用来存储线程的标识符。
  • 一个构造函数,用来初始化线程对象。
  • 一个析构函数,用来销毁线程对象。
  • 一个Start方法,用来启动线程,并传递一个可选的参数。
  • 一个Join方法,用来等待线程结束,并获取其返回值。
  • 一个Detach方法,用来分离线程,使其在结束时自动释放资源。
  • 一个Cancel方法,用来取消线程的执行。
  • 一个静态的辅助函数,用来作为pthread_create函数的第三个参数,即线程要执行的函数。
  • 一个虚函数,用来作为辅助函数的参数,即线程要执行的具体逻辑。

我们可以定义一个基类Thread如下:

class Thread {
public:
    Thread(); // 构造函数
    virtual ~Thread(); // 析构函数

    bool Start(void * arg = NULL); // 启动线程
    void Join(); // 等待线程结束
    void Detach(); // 分离线程
    void Cancel(); // 取消线程

protected:
    virtual void Run(void * arg) = 0; // 线程要执行的具体逻辑

private:
    pthread_t tid; // 线程标识符
    static void * ThreadFunc(void * arg); // 辅助函数
};

线程类的实现

接下来,我们要实现线程类的各个成员函数。首先是构造函数和析构函数:

Thread::Thread() {
    // 初始化tid为0
    tid = 0;
}

Thread::~Thread() {
    // 如果tid不为0,则调用Cancel方法
    if (tid != 0) {
        Cancel();
    }
}

然后是Start方法:

bool Thread::Start(void * arg) {
    // 调用pthread_create函数创建新线程,并将this指针作为参数传递给辅助函数
    int ret = pthread_create(&tid, NULL, ThreadFunc, this);
    // 如果成功创建,则返回true;否则返回false
    return ret == 0;
}

然后是Join方法:

void Thread::Join() {
    // 调用pthread_join函数等待当前线程结束,并将tid置为0
    pthread_join(tid, NULL);
    tid = 0;
}

然后是Detach方法:

void Thread::Detach() {
    // 调用pthread_detach函数分离当前线程,并将tid置为0
    pthread_detach(tid);
    tid = 0;
}

然后是Cancel方法:

void Thread::Cancel() {
    // 调用pthread_cancel函数取消当前线程,并将tid置为0
    pthread_cancel(tid);
    tid = 0;
}

最后是辅助函数和虚函数:

void * Thread::ThreadFunc(void * arg) {
    // 将arg转换为Thread类型的指针,并调用其Run方法
    Thread * ptr = (Thread *)arg;
    ptr->Run(ptr->arg);
    return NULL;
}

void Thread::Run(void * arg) {
    // 这是一个纯虚函数,需要在子类中重写
}

线程类的使用

有了这个基类Thread之后,我们就可以继承它并重写Run方法来实现自己想要的线程逻辑。例如,我们可以定义一个打印信息的子类PrintThread如下:

class PrintThread : public Thread {
public:
    PrintThread(const char * msg); // 构造函数
protected:
    virtual void Run(void * arg); // 重写Run方法
private:
    const char * message; // 要打印的信息
};

PrintThread::PrintThread(const char * msg) {
    // 初始化message为msg
    message = msg;
}

void PrintThread::Run(void * arg) {
    // 打印message和当前线程ID
    printf("%s from thread %lu\n", message, pthread_self());
}

然后我们可以在主程序中创建并使用PrintThread对象:

#include 
#include 

int main() {
// 创建两个PrintThread对象,分别传递不同的信息
PrintThread pt1("Hello");
PrintThread pt2("World");

// 启动两个线程
pt1.Start();
pt2.Start();

// 等待两个线程结束
pt1.Join();
pt2.Join();

// 打印主线程ID
printf("Main thread %lu done\n", pthread_self());

return 0;

输出结果:

```shell
Hello from thread 140408667936512
World from thread 140408659543808
Main thread 140408676329280 done

当然,这只是一个简单的例子,我们还可以在线程类中添加更多的功能和属性满足不同需求。


【Linux】多线程01 --- 理解线程 线程控制及封装_第18张图片

你可能感兴趣的:(Linux系统编程,linux,运维,服务器,多线程)