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

简介: 【Linux】多线程01 --- 理解线程 线程控制及封装(下)

细致讲解一下 pthread_join()函数


有些人可能觉得join的第二个参数不太好理解,所以这里在细说一下这个部分,以前如果我们想拿到一个函数中的多个返回值,但由于函数的返回值只能有一个,所以为了拿到多个返回值,我们都是在调用函数之前,定义出想要拿到的返回值的类型的变量,然后把这个变量的地址传给需要调用的函数,这样的函数参数我们称为输出型参数,然后在函数内部会通过解引用输出型参数的方式,将函数内部的某个需要返回给外部的值拷贝到解引用后的参数里面,那其实就是修改了我们函数外部定义的变量的值。


这里不好理解的原因其实是因为二级指针,我们想要拿到的线程函数的返回值是一个指针,不再是一个变量,所以在调用join的时候,仅仅传一级指针是不够的,我们需要传一级指针变量的地址,让join内部能解引用一级指针变量的地址,拿到外面的一级指针内容并对其修改。


70ef2a5f1e13458781ad703d4645efb0.png


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


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


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


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


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


除线程库要在用户层创建一个描述线程的数据结构外,实际操作系统还会给用户层的TCB创建出来对应的轻量级进程内核数据结构,进行内核中轻量级进程的管理。

所以可以认为,线程是POSIX库中实现了一部分,操作系统中实现了一部分。每当我们创建一个线程时,库就要帮我们在用户层创建出对应的线程控制块TCB,来对库中的多个线程进行管理,同时操作系统还要在对应的创建出轻量级进程。


所以,Linux用户级线程 : 内核轻量级进程 = 1:1。用户关心的线程属性在用户级线程中,内核提供轻量级进程(线程)的调度!


内核中创建轻量级进程调用的接口就是clone,它可以帮助我们创建出linux认为的"线程"。


f1cb658e80d04f40bffdffaa9bcff44b.png


像之前所使用的join函数的第一个参数,也就是tid,他就是TCB的起始地址,也就是指向TCB结构体的指针,而线程函数的返回值实际会写到TCB结构体中的某一个字段,join函数需要tid这个地址,实际就会通过这个结构体指针从TCB中拿到表示线程函数返回值的那个字段的内容。然后将其写到join的第二个参数 void **retval里面。


并且我们现在也能回头去理解一些东西了,例如为什么叫用户级线程库,当然是因为线程库会被映射到虚拟地址空间的映射段啊,而映射段不就是在用户空间吗?线程库的代码都是跑在用户空间的上的,所以线程库也叫用户级线程库。


1942c9bdbf854a38af13bde90ba8c3a0.png




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 <stdio.h>
#include <pthread.h>
__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 <stdio.h>
#include <unistd.h>
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;


输出结果:


43390a3b198a40c2953f9bd7a74d4eeb.png


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

相关文章
|
9天前
|
存储 Linux API
【Linux进程概念】—— 操作系统中的“生命体”,计算机里的“多线程”
在计算机系统的底层架构中,操作系统肩负着资源管理与任务调度的重任。当我们启动各类应用程序时,其背后复杂的运作机制便悄然展开。程序,作为静态的指令集合,如何在系统中实现动态执行?本文带你一探究竟!
【Linux进程概念】—— 操作系统中的“生命体”,计算机里的“多线程”
|
22天前
|
存储 网络协议 Linux
【Linux】进程IO|系统调用|open|write|文件描述符fd|封装|理解一切皆文件
本文详细介绍了Linux中的进程IO与系统调用,包括 `open`、`write`、`read`和 `close`函数及其用法,解释了文件描述符(fd)的概念,并深入探讨了Linux中的“一切皆文件”思想。这种设计极大地简化了系统编程,使得处理不同类型的IO设备变得更加一致和简单。通过本文的学习,您应该能够更好地理解和应用Linux中的进程IO操作,提高系统编程的效率和能力。
69 34
|
22天前
|
Python
python3多线程中使用线程睡眠
本文详细介绍了Python3多线程编程中使用线程睡眠的基本方法和应用场景。通过 `time.sleep()`函数,可以使线程暂停执行一段指定的时间,从而控制线程的执行节奏。通过实际示例演示了如何在多线程中使用线程睡眠来实现计数器和下载器功能。希望本文能帮助您更好地理解和应用Python多线程编程,提高程序的并发能力和执行效率。
46 20
|
25天前
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
本文详细介绍了如何在Linux中通过在业务线程中注册和处理信号。我们讨论了信号的基本概念,并通过完整的代码示例展示了在业务线程中注册和处理信号的方法。通过正确地使用信号处理机制,可以提高程序的健壮性和响应能力。希望本文能帮助您更好地理解和应用Linux信号处理,提高开发效率和代码质量。
45 17
|
1月前
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
通过本文,您可以了解如何在业务线程中注册和处理Linux信号。正确处理信号可以提高程序的健壮性和稳定性。希望这些内容能帮助您更好地理解和应用Linux信号处理机制。
57 26
|
24天前
|
安全 Java 开发者
【JAVA】封装多线程原理
Java 中的多线程封装旨在简化使用、提高安全性和增强可维护性。通过抽象和隐藏底层细节,提供简洁接口。常见封装方式包括基于 Runnable 和 Callable 接口的任务封装,以及线程池的封装。Runnable 适用于无返回值任务,Callable 支持有返回值任务。线程池(如 ExecutorService)则用于管理和复用线程,减少性能开销。示例代码展示了如何实现这些封装,使多线程编程更加高效和安全。
|
27天前
|
安全 Java C#
Unity多线程使用(线程池)
在C#中使用线程池需引用`System.Threading`。创建单个线程时,务必在Unity程序停止前关闭线程(如使用`Thread.Abort()`),否则可能导致崩溃。示例代码展示了如何创建和管理线程,确保在线程中执行任务并在主线程中处理结果。完整代码包括线程池队列、主线程检查及线程安全的操作队列管理,确保多线程操作的稳定性和安全性。
|
3月前
|
NoSQL Redis
单线程传奇Redis,为何引入多线程?
Redis 4.0 引入多线程支持,主要用于后台对象删除、处理阻塞命令和网络 I/O 等操作,以提高并发性和性能。尽管如此,Redis 仍保留单线程执行模型处理客户端请求,确保高效性和简单性。多线程仅用于优化后台任务,如异步删除过期对象和分担读写操作,从而提升整体性能。
87 1
|
4月前
|
数据采集 Java Python
爬取小说资源的Python实践:从单线程到多线程的效率飞跃
本文介绍了一种使用Python从笔趣阁网站爬取小说内容的方法,并通过引入多线程技术大幅提高了下载效率。文章首先概述了环境准备,包括所需安装的库,然后详细描述了爬虫程序的设计与实现过程,包括发送HTTP请求、解析HTML文档、提取章节链接及多线程下载等步骤。最后,强调了性能优化的重要性,并提醒读者遵守相关法律法规。
132 0
|
3月前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
269 2