【Linux】线程(轻量级进程)

目录

一、线程概念

二、线程特性

2.1 进程更加轻量化

2.2 线程的优点 

2.3 线程的缺点

2.4 线程的异常

2.5 线程用途

三、进程和线程

四、线程控制

4.1 包含线程的编译链接

4.2 创建线程

4.3 获得线程自身的ID

4.4 线程终止

4.5 线程等待

4.6 线程分离

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

五、重谈文件系统(地址空间、页表、物理内存)

5.1 物理内存

5.2 页表

六、Linux线程互斥

6.1 进程线程间的互斥相关背景概念

6.2 互斥量mutex 

6.3 互斥量的接口

6.4 互斥量实现原理探究

七、可重入VS线程安全

7.1 概念

7.2 可重入和线程安全的各种情况

八、死锁(Deadlock)

九、 Linux线程同步

9.1 条件变量

9.2 同步概念与竞态条件

9.3 条件变量函数

9.4 生产者消费者模型

9.5 基于BlockingQueue的生产者消费者模型


一、线程概念

在操作系统课本中讲到线程是比进程更加轻量化的一种执行流,即线程是在进程内部执行的一种执行流。对于具体的Linux系统,线程是CPU调度的基本单位,进程是承担系统资源的基本实体。

进程中有PCB包含内核数据结构,指向地址空间、页表等。那么在进程的数据结构层面上,只创建PCB,和父进程指向同一块地址空间。在资源划分上,代码和数据划分成不同部分,每个进程的数据私有,执行一部分代码。在执行进程时,只用按照顺序执行这个PCB的代码部分即可。我们把这种PCB(task_struct)称为轻量级进程(Light Weight Process,LWP)。一个轻量级进程就是一个执行流,之前讲解的进程是一种内部只有一个执行流的进程。今天的进程是内部有多个执行流的进程。

  • 因为线程和父进程指向同一块地址空间,同一个进程的线程大部分资源都是共享的。
  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
  • 一切进程至少都有一个执行线程。
  • 线程在进程内部运行,本质是在进程地址空间内运行。
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

二、线程特性

2.1 进程更加轻量化

1. 线程切换时不用更换地址空间、页表,即不用切换所有的寄存器,只用把一些临时变量的寄存器更换即可,而进程切换要所有的相关寄存器全部切换。

2. 线程级切换不需要切换cache,进程级切换需要切换cache,因为原本数据不需要也没意义了。

补充:

CPU中有一个cache用来保存一些热数据(把保存在cache的一部分代码和数据叫做热数据),高频访问的数据和较大概率访问的数据(当前代码的上下文)缓存到cache,如果缓存失效就重新缓存。这使用了局部性原理,给预加载机制,提供理论基础。

时间局部性(Temporal Locality):这是指如果一个数据项被访问了一次,那么它在不久的将来很可能再次被访问。因此,将这些数据保存在缓存中可以提高访问速度,因为下一次访问时很可能直接从缓存中获取,而不是从更慢的内存中获取。
空间局部性(Spatial Locality):这是指如果一个数据项被访问了,那么与它相邻的数据项也很可能被访问。因此,当CPU访问一个数据项时,它可能会预先加载该数据项附近的多个数据项到缓存中,以便在未来需要时快速访问。

2.2 线程的优点 

  1. 创建一个新线程的代价要比创建一个新进程小得多。
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
  3. 线程占用的资源要比进程少很多。
  4. 能充分利用多处理器的可并行数量。
  5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
  6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
  7. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

2.3 线程的缺点

  1. 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  2. 健壮性降低(一个线程崩溃,整个进程就崩溃了)
    编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  3. 缺乏访问控制(共享内存,数据可被多个线程访问)
    进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  4. 编程难度提高
    编写与调试一个多线程程序比单线程程序困难得多

2.4 线程的异常

  1. 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
  2. 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程是承担分配系统资源的基本实体,线程也是申请资源的一部分,进程终止,该进程内的所有线程也就随即退出。

2.5 线程用途

  • 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
  • 多CPU系统中,使用线程提高CPU利用率:对于多核心cpu来说,每个核心都有一套独立的寄存器用于进行程序处理,因此可以同时将多个执行流的信息加载到不同核心上并行运行,充分利用cpu资源提高处理效率 
  • 耗时的操作使用线程,提高应用程序响应。使用多线程可以更加充分利用cpu资源,使任务处理效率更高,进而提高程序响应。

三、进程和线程

1. 进程是资源分配的基本单位。

2. 线程是调度的基本单位。

3. 线程共享进程数据,但也拥有自己的一部分数据:
        线程ID
        一组寄存器(保存上下文数据)
        栈(独立的栈结构)
        errno
        信号屏蔽字
        调度优先级

4. 进程的多个线程共享同一个地址空间。线程只是在进程虚拟地址空间中拥有相对独立的一块空间,但是本质上说用的是同一个地址空间。因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

        文件描述符表
        每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数handler表)
        当前工作目录(cwd)
        用户id和组id

5. 线程和进程都可并发执行。

6. 线程的粒度小于进程,占用资源更少,因此通常多线程比多进程并发性更高。

7. 进程是资源的分配单位,所以线程并不拥有系统资源,而是共享使用进程的资源,进程的资源由系统进行分配。

8. 线程使用公共变量/内存时需要使用同步机制,因为他们在同一地址空间内。

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

四、线程控制

4.1 包含线程的编译链接

Linux没有真正的线程呢,只有轻量级进程的概念。所以Linux OS只会提供轻量级进程创建的系统调用,不会直接提供线程创建的接口。为了和其它OS统一,Linux实现了一个软件层,对上提供了线程的控制接口,使得用户可以像使用其他操作系统一样创建和管理线程,用户认为自己创建了一个线程,实际上该线程在内核对应成一个LWP。软件层不属于OS,是由系统调用者封装的一个库:pthread原生线程库。后面讲到创建线程和创建LWP是同一个含义。

这也是Linux的一大亮点,实现了软件分层,接口和实现分离,很容易解耦,未来原生线程库想更新,也不会影响内核。每一款Linux系统都要配备pthread库,因此它叫原生线程库。

因此它不属于OS,也不属于C/C++,所以编译链接时要加上 -lpthread 选项指定库
-l选项后面跟的是库的名称,不包含前缀lib和后缀.a或.so
库的名字是去掉前缀lib、去掉后缀版本和.so,即pthread

该库在如下路径中:

 举例创建线程:

mythread:testThread.cc
	g++ -o $@ $^ -std=c++11 -lpthread

.PHONY:clean
clean:
	rm -f mythread
#include <iostream>    
#include <unistd.h>    
#include <pthread.h>    
    
void *NewTread(void *arg)    
{    
    const char *threadName = (const char *)arg;    
    while(true)    
    {    
        std::cout << "I am a new thread: " <<threadName << std::endl;        sleep(1);
    }                
}        
     
    
int main()
{             
    pthread_t tid;
    pthread_create(&tid, nullptr, NewTread, (void *)"thread 1");
                                                                    
    while(true)
    {              
        std::cout << "Main tread" << std::endl;
        sleep(1);                                                       
    }
         
    return 0;
}  

使用ps -aL查看进程状态,LWP对应是轻量级进程的编号。同一进程的多个线程PID相同,主线程的PID和LWP相同,可以用来判断线程是否为主线程。

4.2 创建线程

功能:创建一个新的线程
原型

       #include <pthread.h>

       int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);

参数
thread:输出型参数,返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:函数地址,线程启动后要执行的函数。
arg:传给线程启动函数的参数
返回值:成功返回0,失败返回错误码

 start_routine:函数地址,线程启动后要执行的函数。
这个函数的返回值和参数都是void * ,它可以接受任意类型的参数和返回任意类型的结果。

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
  • pthreads同样也提供了线程库内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过检查返回值来确定函数是否成功执行,因为读取返回值要比读取线程库内的errno变量的开销更小。

创建多线程:

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <functional>
#include <string>
#include <vector>

using func_t = std::function<void()>;
const int threadNum = 5;//创建线程的数量

class ThreadData
{
public:
    ThreadData(const std::string& name, const uint64_t& ctime, func_t f)
        :threadName(name)
         ,createTime(ctime)
         ,func(f)
    {}

public:
    std::string threadName;
    uint64_t createTime;
    func_t func;
};

void Print()
{
    std::cout <<  "线程执行中......" << std::endl;
}

void* ThreadRoutine(void* args)
{
    int a = 10;
    ThreadData* ptd = static_cast<ThreadData*>(args);
    while(true)
    {
        std::cout << "new thread, name: " << ptd->threadName << " createTime: " << ptd->createTime << std::endl;
        ptd->func();
        if(ptd->threadName == "thread-4")
        {
            std::cout << ptd->threadName << " 触发了异常!!!!!" << std::endl;
            a /= 0;//制造异常
        }
        sleep(1);
    }
}

int main()
{
    std::vector<pthread_t> pthreads;
    for(size_t i = 0; i < threadNum; i++)
    {
        char threadName[20];
        snprintf(threadName, sizeof(threadName),"%s-%lu","thread",i);
        pthread_t tid;
        ThreadData* ptd = new ThreadData(threadName, (uint64_t)time(nullptr), Print);
        pthread_create(&tid, nullptr, ThreadRoutine, ptd);
        pthreads.push_back(tid);
        sleep(1);
    }
    std::cout << "thread id : ";
    for(const auto& tid:pthreads)
    {
        std::cout << tid <<"  ";
    }
    std::cout << std::endl;
    
    while(true)
    {
        std::cout << "main thread" << std::endl;
        sleep(1);
    }

    return 0;
}

 如果不制造异常,正常运行线程tid如下:

线程ID和LWP编号不同

  • 线程ID(TID):在Linux中,当使用如pthread_self()这样的函数获取线程ID时,得到的是一个线程标识符,它实际上是一个指向线程控制块(Thread Control Block, TCB)的指针,这个指针在内存地址空间中是唯一的。因此将其打印出来时,会看到一个像140653110572800这样的内存地址值(16进制:0x7FEC 5AB1 3700)。这个值对于用户空间程序来说并没有直接的用途,除了调试目的之外。
  • 轻量级进程(LWP):在Linux的NPTL(Native POSIX Thread Library)实现中,每个线程在内核级别上都有一个与之关联的轻量级进程(LWP)。LWP是内核用来调度线程的资源,它使得线程看起来就像是一个普通的进程(尽管它们共享相同的地址空间)。当你使用ps -aL或类似的命令查看线程时,LWP列显示的是这些轻量级进程的ID,这些ID在内核级别上是唯一的,并且与用户空间的线程ID不同。

4.3 获得线程自身的ID

pthread_ self函数

功能:获取调用该函数的线程的线程ID。

原型:

       #include <pthread.h>

       pthread_t pthread_self(void);

返回值:

        返回一个指向pthread_t变量的指针,其中存储了调用线程的线程ID

4.4 线程终止

在Linux中,如果需要让线程终止,不能直接使用exit函数,因为exit是用于进程终止的。

如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
2. 线程可以调用pthread_ exit终止自己。
3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

pthread_exit函数

功能:线程终止
原型:

       #include <pthread.h>

       void pthread_exit(void *retval)
参数:
        retval:输出型参数,输出线程启动后执行函数的返回值。不要指向一个局部变量。
返回值:
        无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

void* ThreadRunning(void* args)
{
    string name = static_cast<const char*>(args);
    int cnt = 5;
    while (cnt--)
    {
        cout << "new thread is running, thread name: " << name << "thread id: " << ToHex(pthread_self()) << endl;
        sleep(1);
    }
    
    //return (void*)"thread-1 done";//返回字符串常量的起始地址
    pthread_exit((void*)"thread-1 done");//两种退出方式结果相同
}

pthread_cancel函数

功能:取消一个执行中的线程
原型

       #include <pthread.h>

       int pthread_cancel(pthread_t thread);

参数:
        thread:线程ID
返回值:

        成功返回0;失败返回错误码

注意:

  • pthread_exit的参数和return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。 
  • 主线程调用pthread_cancel(pthread_self())函数来退出自己, 则主线程对应的轻量级进程状态变更成为Z, 其他线程不受影响。(但正常情况下我们不会这么做....)
  • 主线程调用pthread_exit只是退出主线程,并不会导致进程的退出。

4.5 线程等待

为什么需要线程等待?

  1. 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,没有等待,会导致类似进程的僵尸问题。
  2. 线程退出时,主线程要获取新线程的返回值。
  3. 创建新的线程不会复用刚才退出线程的地址空间。

线程默认要被等待!

功能:等待线程结束
原型:

       #include <pthread.h>

       int pthread_join(pthread_t thread, void **value_ptr);
参数:
        thread:要等待的线程ID
        value_ptr:指向线程的返回值。
如果我们要得到新线程的返回值,我们得到的应该是void*,为了得到一个void*,需要传入一个void**,所以value_ptr的类型为void **
返回值:

        成功返回0;失败返回错误码

注意:调用该函数的线程将挂起等待,直到id为thread的线程终止。

thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。(同1)
  3. 如果thread线程被别的线程调用pthread_ cancel取消,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED,即-1。
    库中定义#define PTHREAD_CANCELED ((void *) -1)
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
  5. 如果线程发生异常,进程直接收到信号结束,不需要再考虑线程的事了。
string ToHex(pthread_t tid)
{
    char id[64];
    snprintf(id, sizeof(id), "0x%lx", tid);
    return id;
}

void *ThreadRunning(void *args)
{
    string name = static_cast<const char *>(args);
    int cnt = 5;
    while (cnt--)
    {
        cout << "new thread is running, thread name: " << name << "thread id: " << ToHex(pthread_self()) << endl;
        sleep(1);
    }
    // return (void*)"thread-1 done";//返回字符串常量的起始地址
    pthread_exit((void *)"thread-1 done");//两种退出方式结果相同
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, ThreadRunning, (void *)"thread-1");

    void *ret = nullptr;
    int n = pthread_join(tid, &ret);
    cout << "main thread  join done, "
         << "n: " << n << " thread return: " << (char *)ret << endl;

    return 0;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, ThreadRunning, (void *)"thread-1");
 
    sleep(3);
    int n = pthread_cancel(tid);
    cout << "main thread cancel done,"
        << " n: " << n << endl;

    void *ret = nullptr;
    n = pthread_join(tid, &ret);
    cout << "main thread  join done, "
        << "n: " << n << " thread return: " << (int64_t)ret << endl;// 库中定义#define PTHREAD_CANCELED ((void *) -1)

    return 0;
}

4.6 线程分离

线程是可以被设置为分离状态的(情况:等待会阻塞型的等待,不等待又可能会引发类似僵尸进程的问题,如果不想等待可以将线程设置为分离状态,此时OS自动管理线程退出,不再需要用户关心)

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

功能:设置目标线程为分离状态

原型:

       #include <pthread.h>

       int pthread_detach(pthread_t thread);

参数:

        thread:目标线程ID

返回值:

        成功返回0;失败返回错误码

注意:

  • joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
  • 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离 :
    pthread_detach(pthread_self());
  • 如果线程没有被分离但被取消,线程join的thread return结果为-1

在Linux中,当线程被设置为分离状态后,它的资源会在线程终止时由操作系统自动回收,因此父线程无法通过pthread_join函数等待分离线程的终止。下面示例展示了在创建线程时将其设置为分离状态,然后尝试使用pthread_join函数等待线程终止,从而引发错误 。

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, ThreadRunning, (void *)"thread-1");

    sleep(3);
    pthread_detach(tid);
    int n = pthread_cancel(tid);
    cout << "main thread cancel done,"
         << " n: " << n << endl;

    sleep(1);
    void *ret = nullptr;
    if (pthread_join(tid, &ret))
    {
        std::cerr << "Error joining thread" << std::endl;
        return 1;
    }

    return 0;
}

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

pthread_ create函数会产生一个线程ID(TID),类型为pthread_t ,存放在第一个参数指向的地址中。该TID和前面说的进程PID不是一回事。前面讲的PID属于进程调度的范畴。这里的TID是操作系统调度器用于调度线程的最小单位。每个线程在创建时都会被分配一个唯一的TID,这个TID在整个进程的生命周期内是固定的。TID通常是由操作系统内核分配的,并且是线程调度的关键。

pthread原生线程库提供了线程管理的功能,包括创建、同步、通信等。线程库使用一个名为pthread_t 的数据类型来表示线程ID。当使用 pthread_create 函数创建新线程时,它会返回一个指向pthread_t变量的指针,这个变量中存储了新创建线程的线程ID。线程库的后续操作,就是根据该线程ID来控制线程的。

对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

补充:POSIX(可移植操作系统接口)是一个公认的工业标准,NPTL(Native POSIX Thread Library)是Linux上广泛使用的POSIX线程库,pthread原生线程库是指实现了POSIX线程标准的线程库。在Linux上,pthread原生线程库通常是指NPTL,但也可以是其他线程库,如LinuxThreads。LinuxThreads是较早期的Linux线程库,它在NPTL出现之前被广泛使用。

C++11也有多线程,但其本质是对原生线程库的封装,编译链接时还需要加上-lpthread选项。

 C++的多线程的简化如下:

template<class T>
using func_t = std::function<void(T)>;

template<class T>
class Thread
{
public:
	Thread(const std::string &threadname, func_t<T> func, T data)
		:_tid(0),
		_threadname(threadname),
		_isrunning(false),
		_func(func),
		_data(data)
	{}

	static void *ThreadRoutine(void *args)	//成员函数隐含的第一个参数为this
	{
		Thread *pt = static_cast<Thread *>(args);

		pt->_func(pt->_data);
		return nullptr;
	}

	bool Start()
	{
		int n = pthread_create(&_tid, nullptr, ThreadRoutine, this);
		if(n == 0)
		{
			std::cout << _threadname << " starts running..." << std::endl;
			_isrunning = true;
			return true;
		}
		return false;
	}

	bool Join()
	{
		if(!_isrunning) return true;
		int n = pthread_join(_tid, nullptr);
		if(n == 0)
		{
			_isrunning = false;
			return true;
		}
		return false;
	}

	std::string ThreadName()
	{
		return _threadname;
	}

	bool IsRunning()
	{
		return _isrunning;
	}

	~Thread()
	{}
private:
    pthread_t _tid;
    std::string _threadname;
    bool _isrunning;
    func_t<T> _func;
    T _data;
};

前面用到的线程控制的接口全部都不是系统直接提供的接口,而是pthread原生线程库提供的接口。创建的线程属于用户级线程。用户态线程的切换在用户态实现,不需要内核支持。Linux为了适配各种教程和其它高级语言设计了pthread原生线程库,用户就可以按照习惯直接创建和控制线程,但是Linux底层还是LWP轻量级进程,是通过pthread原生线程库来让每一个线程对应一个LWP。

内核中有许多进程、每个进程又可能会有多个线程,pthread原生线程库就要对线程进行管理,也是先描述再组织,可以理解为有一个tcb结构体包含着线程的各种属性和数据,一部分来自系统的轻量级进程,一部分来自pthread库和用户。

前面也讲到线程要有属于自己的数据,如上下文数据和栈,上下文是以轻量级进程的方式维护在PCB中,用户不用考虑。用户要考虑为新创建线程分配栈,因为栈只有一个(维护栈的寄存器只有一套)。维护栈还有一个原因,pthread_ create函数的底层是调用clone 函数(创建子进程的fork也是),clone函数原型如下:

#include <sched.h>

int clone(int (*fn)(void *), void *child_stack,int flags, void *arg, ...);

参数:

  • fn:指向新创建的轻量级进程/子进程执行的函数。
  • child_stack:允许用户传入栈空间。(大部分是开辟一段堆空间,当作栈使用,如malloc)
  • flags:用于区分创建的是真正的子进程还是轻量级进程。

每个新线程的栈,都需要在库中维护;默认地址空间中的栈,由主线程使用

动态库也叫做共享库,系统中只需要加载一次,会映射到其它进程。由于管理线程的结构体在线程库中,所以线程库可以看到系统中多个用户创建的所有线程,线程库要管理系统中创建的所有线程,动态库加载到内存,映射到地址空间的共享区,就要在共享区中实现对线程的管理。

内核数据结构大致如下:

其中mmap区域就是我们知道的共享区,线程的TCB(类似进程PCB)就是图中的每一个结构体,结构体中包括

1. 线程结构体(struct pthread):

  • 每个线程都有一个struct pthread实例,它包含了线程的上下文信息,如线程ID、线程状态、线程函数指针、线程局部数据等。
  • struct pthread实例通常位于线程栈的顶部,它的大小和布局可能会根据编译时的选项和系统配置而有所不同。

2. 线程局部数据(Thread Local Data):

  • 线程局部数据是为了在多线程环境中存储线程特定的数据。每个线程都有自己的线程局部数据区域,它位于线程栈之外,通常是在线程栈的顶部。
  • 线程局部数据可以包括静态变量、全局变量或其他数据结构,它们在多线程环境中为每个线程提供独立的副本。

3. 线程栈(Thread Stack):

  • 线程栈是线程执行时使用的内存区域,它用于存储局部变量、函数调用栈帧和返回地址。
  • 线程栈的大小通常由编译器选项或系统配置决定,但至少要足够容纳一个函数调用栈帧。
  • 线程栈的底部通常紧邻线程局部数据区域,栈顶则指向线程结构体。

当线程被创建时,操作系统会分配足够的内存空间来容纳这些区域。线程结构体通常位于线程栈的顶部,线程局部数据紧随其后,线程栈则位于线程局部数据下方。这种布局确保了线程栈不会与线程局部数据或其他线程的结构体发生冲突。

在多线程应用程序中,这种内存布局允许每个线程独立地运行,并且不会相互干扰。线程栈为每个线程提供了一个独立的执行环境,而线程局部数据则允许线程存储和管理自己的数据。

我们在使用pthread_create函数生成的pthread_t类型的tid参数就是线程结构体在库中的地址。在使用pthread_join(tid, &ret)函数得到的ret就是从这个结构体中拿到的

注意:

  • LWP是内核的概念,这个tid是库中的概念。
  • 线程栈可以是个指针,指向分配空间的地址。

五、重谈文件系统(地址空间、页表、物理内存)

5.1 物理内存

文件系统IO的基本单位大小是4KB,这意味着文件系统将磁盘空间分割成4KB大小的块,用于存储文件数据。当文件被写入或读取时,操作是以这些4KB的块为单位进行的。4KB的块大小被认为是一个平衡点,它既不会太小以至于频繁触发磁盘寻址,也不会太大以至于在内存和磁盘之间传输数据时效率低下。

在物理内存中,页框(Page Frame)是一个连续的4KB块是操作系统中内存管理的基本单位。每个页框都有一个唯一的物理地址,并且每个页框都包含一个或多个页表项(Page Table Entries, PTEs),这些页表项用于映射虚拟内存地址到物理内存地址。

页框的概念与分页内存管理有关,在这种管理模式下,内存被划分为固定大小的块,每个块都可以被分配给进程的虚拟内存。当进程需要访问内存时,操作系统会将虚拟地址转换为物理地址,这通常是通过页表来完成的。每个页表项都指向一个页框,而页框则包含了实际的数据或程序指令。

页框是内存管理单元(MMU)中的一个重要概念,MMU负责处理虚拟地址到物理地址的转换。当进程访问虚拟内存中的一个地址时,MMU会检查相应的页表项,如果页表项指向一个有效的页框,那么MMU就会将虚拟地址转换为物理地址,并允许CPU访问页框中的数据。

在Linux内核中,页框的管理是内存管理的一部分,先描述再管理,内核使用struct page描述物理内存中页框的使用情况和属性,例如设计flags位字段,用每一个比特位跟踪哪些页框被使用、哪些是空闲的,并根据需要进行分配和回收。当内存不足时,内核可能会将一些页框从物理内存中移除,并将其内容交换到磁盘上的交换分区中,这个过程称为交换(swap)。

在磁盘生成的可执行程序也是以4KB的页框为单位,这保证数据会被以4KB的块大小从磁盘读取或写入。当文件被读取时,文件系统会从磁盘加载4KB的块到缓存中,以供后续的读取操作使用。同样,当文件被写入时,数据首先写入缓存中的4KB块,然后由缓存管理器(如buffer cache)决定何时将脏块(即被修改过的块)写回磁盘。

5.2 页表

地址空间的地址很多,页表做不到一一映射。在Linux中,页表是一个多级结构,可以简单理解为包括页目录和页表。给线程分配代码和数据的本质就是在划分页表,划分页表的本质就是划分地址空间。在进程视角,虚拟地址本身就是资源!

虚拟地址有32个比特位,前10个比特位指向特定的页目录(共1024个,2^10),中间10位根据页目录指向的页表确定物理内存中具体的页框(共1024个,2^10),再根据最后12位确定页框中的起始地址(2^12正好对应4KB)。

这样最多只需要2^10 * 2^10 = 2^20个页表就可以完成页表的映射。为了更节省空间,一级页表要全都有,二级页表不一定全部加载。因为虚拟地址不一定全部都用到。根据局部性原理,只用了前两个页目录来访问页表,如访问代码区的数据,此时后面的二级页表就不加载,需要加载时发生缺页中断,创建页表,建立映射关系,这使得页表数目又大大降低了。

经过汇编后每一个函数都有自己的虚拟地址,拥有函数的本质是拥有对应的虚拟地址。在栈、堆开辟空间时能获取空间的地址和大小、变量名。函数中的每个语句占用特定的一部分虚拟地址空间,虚拟地址空间中的变量、函数等可能占用很多字节,但取地址只能取出一个地址,就是它的起始地址,根据它的类型就能确定占用多少字节。类型的本质就是偏移量。

六、Linux线程互斥

6.1 进程线程间的互斥相关背景概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

在Linux操作系统中,特别是在多线程编程中,线程间的互斥用以确保数据一致性和防止竞态条件。

临界资源是需要被互斥访问的资源,因为当多个线程同时访问这类资源时,可能会导致数据不一致或者错误。临界区则是指访问这些临界资源的代码段,它是程序中可能导致竞态条件的部分。

为了保护这些临界资源,需要使用互斥机制,以确保在同一时刻只有一个线程能够执行临界区内的代码。这种机制通常是通过互斥量(mutex)实现的。互斥量是一种同步机制,它可以确保多个线程不会同时进入临界区。线程在进入临界区之前必须锁定互斥量,完成对临界资源的访问后,必须解锁互斥量,以便其他线程可以锁定并访问临界区。

原子性指的是一个操作或者一系列操作要么全部完成,要么全部不完成,不会出现中间状态。在多线程环境中,原子操作是非常重要的,因为它们可以保证操作的完整性,不会因为线程切换而被打断。

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。

6.2 互斥量mutex 

在Linux系统中,互斥量的实现在很大程度上依赖于POSIX线程(pthread)库。例如,pthread_mutex_lock 和 pthread_mutex_unlock 函数分别用于锁定和解锁互斥量。正确使用这些函数可以有效地避免竞态条件和数据不一致的问题。

此外,互斥量使用时需要注意以下几点:
1. 死锁避免:线程在请求互斥量时,如果已经持有另一个互斥量,并且其他线程持有它请求的互斥量,则可能会发生死锁。设计程序时应该注意互斥量的锁定顺序,避免死锁的发生。
2. 性能考虑:互斥量虽然可以保护临界资源,但过度使用会影响程序的性能,因为线程可能会频繁地等待获取互斥量。因此,在设计程序时,应该尽量减少临界区的范围,减少互斥量的使用。
3. 错误处理:在锁定和解锁互斥量时,应该总是检查函数的返回值,以处理可能发生的错误。
正确地使用互斥量和其他同步机制,可以保证多线程程序的正确性和稳定性,是高级程序员在开发多线程应用程序时必须掌握的技能。

多线程访问共享资源可能会发生数据不一致问题,举例有问题的售票系统代码如下:

std::string GetThreadName()
{
    static int num = 1;
    char name[64];
    snprintf(name, sizeof(name), "Thread-%d", num++);
    return name;
}

int tickets = 10000;
void GetTicket(pthread_mutex_t *mutex)
{
    (void)mutex;
    while (tickets > 1)
    {
        usleep(1000);
        std::cout << "tickets: " << tickets-- << std::endl;
    }
}

void Test3()
{
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);

    std::string name1 = GetThreadName();
    Thread<pthread_mutex_t *> t1(name1, GetTicket, &mutex);

    std::string name2 = GetThreadName();
    Thread<pthread_mutex_t *> t2(name2, GetTicket, &mutex);

    std::string name3 = GetThreadName();
    Thread<pthread_mutex_t *> t3(name3, GetTicket, &mutex);

    std::string name4 = GetThreadName();
    Thread<pthread_mutex_t *> t4(name4, GetTicket, &mutex);

    std::string name5 = GetThreadName();
    Thread<pthread_mutex_t *> t5(name5, GetTicket, &mutex);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();
    t5.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();
    t5.Join();

    pthread_mutex_destroy(&mutex);
}

int main()
{
    Test3();
    return 0;
}

结果如下,会出现编号0,-1,-2的票,相当于多卖出3张。

原因如下:

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • ticket-- 操作本身不是一个原子性操作。
  • 判断本身也是计算,不是原子操作。

取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152   40064b:    8b 05 e3 04 20 00  mov   0x2004e3(%rip),%eax     # 600b34 <ticket>
153   400651:    83 e8 01                 sub    $0x1,%eax
154   400654:    89 05 da 04 20 00  mov   %eax,0x2004da(%rip)     # 600b34 <ticket>

-- 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。


 

6.3 互斥量的接口

1. 初始化互斥量

初始化互斥量有两种方法:

        1.1 静态初始化:

        pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
使用PTHREAD_MUTEX_INITIALIZER宏可以静态地初始化一个互斥量。这种方法适用于在程序编译时就已经知道互斥量的位置和数量,通常用于全局互斥量

这种方式初始化的互斥量不需要在程序结束时进行清理

        1.2 动态初始化:

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
这种方法更灵活,可以在程序运行时创建和销毁互斥量,适用于互斥量数量不确定或者互斥量在堆上分配的情况。

参数:

  • mutex参数是指向要初始化的互斥量的指针。
  • mutexattr参数是指向pthread_mutexattr_t结构的指针,该结构用于设置互斥量的属性。如果attr为NULL,则使用默认属性。

2. 销毁互斥量

在使用pthread_mutex_init初始化互斥量之后,当不再需要该互斥量时,应该使用pthread_mutex_destroy函数来释放与互斥量相关的资源。

        int pthread_mutex_destroy(pthread_mutex_t *mutex);

这个函数会释放互斥量mutex的所有资源,并且这个互斥量之后必须重新初始化才能使用。如果互斥量已经加锁或者有其他线程在等待这个互斥量,pthread_mutex_destroy会返回错误。

注意:

  •  使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

3. 互斥量加锁和解锁
        3.1 加锁(pthread_mutex_lock)

int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:mutex参数是指向要锁定的互斥量的指针。
返回值:成功时返回0,失败时返回错误号。

当线程调用pthread_mutex_lock尝试获取互斥量时,它会检查互斥量是否已经被锁定。如果互斥量未被锁定,该线程会将互斥量锁定并继续执行。如果互斥量已经被另一个线程锁定,调用线程将被阻塞,直到互斥量被解锁。在阻塞期间,线程可能会被调度器挂起,等待资源可用。

        3.1 解锁(pthread_mutex_unlock)

int pthread_mutex_unlock(pthread_mutex_t *mutex); 
参数:mutex参数是指向要解锁的互斥量的指针。
返回值:成功时返回0,失败时返回错误号。

当线程完成对临界区的访问后,需要调用pthread_mutex_unlock来释放互斥量,这样其他线程就可以获取这个互斥量并进入临界区。

在多线程编程中,正确地使用加锁和解锁操作是至关重要的。不恰当的加锁和解锁可能会导致死锁、竞态条件或者资源泄露等问题。因此,在编写多线程代码时,应当遵循以下原则:

  • 每次进入临界区前必须加锁,多个线程访问共享资源时要加锁是要由程序员自己保证的。
  • 一旦完成临界区的操作,必须解锁。
  • 在同一个线程中,加锁和解锁的次数必须匹配。
  • 谁加锁,谁解锁。
  • 避免在持有锁的情况下进行可能阻塞的操作,如文件I/O、系统调用等。
  • 在设计锁的时候,应该尽量减少锁的粒度,即锁的范围应该尽可能小,以减少线程等待的时间。

 对临界区使用锁(互斥量)之后,结果正确。

void GetTicket(pthread_mutex_t *pmutex)
{
    while (true)
    {
        pthread_mutex_lock(pmutex);
        if (tickets > 0)
        {
            usleep(1000);
            std::cout << "tickets: " << tickets-- << std::endl;
            pthread_mutex_unlock(pmutex);
        }
        else
        {
            pthread_mutex_unlock(pmutex);
            break;
        }
    }
}

在C++中,也可以对锁进行封装

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>

template<class T>
using func_t = std::function<void(T)>;

template<class T>
class Thread
{
public:
	Thread(const std::string &threadname, func_t<T> func, T data)
		:_tid(0),
		_threadname(threadname),
		_isrunning(false),
		_func(func),
		_data(data)
	{}

	static void *ThreadRoutine(void *args)	//成员函数隐含的第一个参数为this
	{
		Thread *pt = static_cast<Thread *>(args);

		pt->_func(pt->_data);
		return nullptr;
	}

	bool Start()
	{
		int n = pthread_create(&_tid, nullptr, ThreadRoutine, this);
		if(n == 0)
		{
			std::cout << _threadname << " starts running..." << std::endl;
			_isrunning = true;
			return true;
		}
		return false;
	}

	bool Join()
	{
		if(!_isrunning) return true;
		int n = pthread_join(_tid, nullptr);
		if(n == 0)
		{
			_isrunning = false;
			return true;
		}
		return false;
	}

	std::string ThreadName()
	{
		return _threadname;
	}

	bool IsRunning()
	{
		return _isrunning;
	}

	~Thread()
	{}
private:
    pthread_t _tid;
    std::string _threadname;
    bool _isrunning;
    func_t<T> _func;
    T _data;
};

6.4 互斥量实现原理探究

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

以下是一些关于互斥量实现原理的详细说明:

  • 原子交换指令: swap或exchange指令通常用于实现互斥锁。这类指令会原子地交换两个位置的数据,例如一个寄存器和一个内存地址。在多线程环境中,这可以用来实现一个简单的锁:线程尝试将一个值(通常是0或1)交换到一个内存地址,如果交换成功,则线程获取锁;如果交换失败,则线程继续尝试或阻塞。
  • 总线锁和缓存一致性: 在多处理器系统中,当一个处理器执行原子操作时,它会通过总线锁或缓存一致性协议来确保其他处理器看不到操作的中途状态。这意味着在原子操作完成之前,其他处理器不能访问相同的内存地址。
  • 测试-设置(Test-and-Set)指令: test-and-set指令是另一种常用的原子指令,它将一个内存位置设置为1,并返回该位置之前的值。这可以用来实现互斥锁:线程尝试执行test-and-set指令,如果返回值是0,则线程获取锁;如果返回值是1,则线程继续尝试或阻塞。
  • 比较-交换(Compare-and-Swap)指令: compare-and-swap(CAS)指令是一个更为强大的原子操作,它比较内存中的值和一个期望值,如果它们相等,则将内存中的值替换为新值,并返回旧值。这可以用来实现锁和其他并发数据结构,如无锁队列。
  • 自旋锁和阻塞: 在实现互斥锁时,线程可能会采用自旋锁或阻塞的方式来等待锁的释放。自旋锁是指线程在循环中不断尝试获取锁,直到成功为止。这种方法适用于锁被占用时间很短的情况。而当锁被占用时间较长时,线程可能会选择阻塞,这样线程会被挂起,直到锁被释放时才被唤醒。
  • 操作系统和调度器: 操作系统调度器在互斥锁的实现中也扮演着重要角色。当线程尝试获取一个已被其他线程持有的锁时,调度器会将该线程设置为阻塞状态,并选择其他线程运行。当锁被释放时,调度器可能会将等待锁的线程设置为就绪状态,并在适当的时候将其调度到运行状态。

假设内存中的 mutex值为1,线程寄存器要加载mutex,需要先将自己寄存器值清0,然后进行swap交换。(原因:数据在内存中,本质是被线程共享的。数据被读取到寄存器中,本质变成了线程的上下文,属于线程私有数据!)这样内存中的mutex就为0了,其它线程要访问临界区就要判断mutex值是否为1,不为1则要等待,直到交换了mutex的线程完成工作,完成后再次交换mutex的值,mutex的值再次为1,其它线程就可以访问该临界区了。

寄存器硬件在cpu内部只有一套,但是寄存器的内容每一个线程都有一份,属于自己的上下文。
交换的作用:将一个共享的mutex资源,交换到自己的上下文中,只属于线程自己。

七、可重入VS线程安全

7.1 概念

线程安全:线程安全是指一个函数、对象或资源在同一时间可以被多个线程安全地访问,而不会导致数据不一致或其他错误。线程安全的实现通常需要使用同步机制,如互斥锁、条件变量、读写锁等,来保护共享资源。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程不安全问题。

可重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

可重入函数通常不依赖于任何全局或静态状态,它们的执行仅依赖于它们的输入参数和局部变量。可重入函数是线程安全的一个子集,因为它们可以被多个线程安全地调用,但线程安全的函数不一定是可重入的。

可重入的特点包括:

  • 不使用全局或静态变量(或者只使用线程局部的全局或静态变量)。
  • 不依赖存储在堆上的数据(或者使用线程局部的存储)。
  • 仅依赖于参数和局部变量。

在实际应用中,如果一个函数是可重入的,那么它通常是线程安全的。但是,一个线程安全的函数不一定是可重入的,因为线程安全可能涉及到锁定机制,而锁定机制可能会阻止同一个函数在持有锁的情况下被重入。

7.2 可重入和线程安全的各种情况

常见的线程不安全的情况

  • 不保护共享变量的函数。(如一个函数读取和修改共享变量,而没有适当的同步机制)
  • 函数状态随着被调用,状态发生变化的函数。
  • 返回指向静态变量指针的函数。(多个线程使用该指针时可能会访问和修改共享数据)
  • 调用线程不安全函数的函数。
  • 多线程访问同一个不可重入函数。

常见的线程安全的情况

  • 只读权限:每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 原子操作:类或者接口对于线程来说都是原子操作。
  • 无二义性:多个线程之间的切换不会导致该接口的执行结果存在二义性。

常见不可重入的情况

  • 使用全局或静态数据:调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
  • 调用不可重入函数:调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构,如标准I/O库函数、malloc/free等。
  • 返回静态或全局数据:可重入函数体内使用了静态的数据结构。

常见可重入的情况

  • 不使用全局变量或静态变量。
  • 不使用动态分配内存,比如用malloc或者new开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都有函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

可重入与线程安全联系

  • 可重入函数是线程安全的一个子集,因为它们可以在多线程环境中安全地被调用。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 可重入函数一定是线程安全的,但线程安全的函数不一定是可重入的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。

八、死锁(Deadlock)

  • 死锁是操作系统和多线程程序中的一个经典问题,它发生在两个或多个执行流(进程或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,如果没有外力干预,这些执行流将无法继续执行下去。

死锁的四个必要条件:

  1. 互斥条件:至少有一个资源是不能被多个执行流共享的,即一个资源每次只能被一个执行流使用。
  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:已经分配给一个执行流的资源在该执行流完成其任务之前不能被强行剥夺。
  4. 循环等待条件:存在一种执行流资源的循环等待链,每个执行流都在等待下一个执行流所持有的资源。

避免死锁:

破坏死锁的四个必要条件:

  1. 破坏互斥条件:允许资源被多个执行流共享,但这在某些情况下可能不可行或不安全。
  2. 破坏请求与保持条件:执行流在开始执行前必须获取所有需要的资源,或者允许执行流释放已持有的资源后再去请求新的资源。
  3. 破坏不剥夺条件:如果一个执行流请求资源失败,它可以释放已持有的所有资源,然后重新开始。
  4. 破坏循环等待条件:可以通过对资源进行编号,并要求执行流只能按照特定的顺序请求资源来避免循环等待。

一个锁也可能形成死锁 ,比如把解锁错写成加锁。

其他避免死锁的策略:

  • 锁排序:确保所有执行流按照一致的顺序加锁,可以防止循环等待。
  • 锁超时:设置锁的超时时间,如果在一个时间内没有获取到锁,执行流可以放弃获取锁,并释放已持有的资源。
  • 资源分配图:使用资源分配图来检测系统是否处于不安全状态,如果检测到不安全状态,可以采取措施避免死锁。
  • 银行家算法:这是一种预防死锁的算法,通过预先分配资源来确保系统不会进入不安全状态。

九、 Linux线程同步

9.1 条件变量

概念:

  • 条件变量允许线程在某些条件不满足时挂起,直到条件满足后被唤醒。
    例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
  • 条件变量通常与互斥锁一起使用,以确保线程安全地访问共享资源。

使用场景:

  • 生产者-消费者问题:这是条件变量最常见的使用场景。生产者线程生产数据,消费者线程消费数据。当队列(共享资源)为空时,消费者线程会阻塞,等待生产者线程向队列中添加数据;当队列满时,生产者线程会阻塞,等待消费者线程从队列中移除数据。
  • 读写锁:在读写锁中,当有多个读线程正在读取数据时,写线程需要等待直到所有读线程完成。条件变量可以用来在写线程等待读线程完成时阻塞。
  • 线程池:线程池中的工作线程可能会使用条件变量来等待新的任务到来。当没有任务时,工作线程会阻塞,直到有新任务提交。

作用,条件变量主要为了防止以下情况而存在:

  • 忙等:没有条件变量,线程可能需要不断地轮询某个条件是否满足,这会导致CPU资源的浪费,并且增加线程切换的开销。
  • 竞态条件:多个线程试图同时检查和修改共享资源时可能会出现竞态条件,条件变量通过互斥锁和条件等待机制来避免这种情况。
  • 死锁:不正确地使用锁可能导致死锁,条件变量通过提供一种机制,使得线程可以在满足特定条件前释放锁,从而避免死锁的发生。
  • 复杂的同步逻辑:条件变量提供了一种简单的方式来同步线程,而不需要编写复杂的逻辑来管理线程的阻塞和唤醒。

9.2 同步概念与竞态条件

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。(饥饿问题是指某个或某些线程因为无法获得所需的资源(如CPU时间、互斥锁、信号量等)而无法继续执行其任务的状态。)

竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。比如两个或多个线程在访问共享资源时,由于执行顺序的不确定性而导致程序输出不可预测或错误的结果。

9.3 条件变量函数

初始化

和互斥量mutex类似,条件变量cond也又静态初始化和动态初始化。(condition的简写)

静态初始化:(通常用于全局或静态的条件变量对象)
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;


动态地初始化一个条件变量:

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);
参数:

  • cond:要初始化的条件变量的指针。
  • cond_attr:指向条件变量属性结构(pthread_condattr_t )的指针,通常设为NULL,表示使用默认属性。

返回值:成功时返回 0,失败时返回一个错误码。

销毁

销毁一个条件变量。一旦条件变量不再需要,应该调用这个函数来释放与条件变量相关的资源。

int pthread_cond_destroy(pthread_cond_t *cond)

cond:指向要销毁的条件变量的指针。

成功时返回 0,失败时返回一个错误码。

等待条件满足

用于在一个条件变量上等待,直到条件满足。这个函数通常与互斥锁一起使用,以确保线程安全地等待条件变量的通知。

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
参数:
cond:要在这个条件变量上等待
mutex:指向与条件变量一起使用的互斥量的指针。在调用 pthread_cond_wait 之前,线程必须已经锁定这个互斥量。pthread_cond_wait 函数等待时会自动解锁互斥量,并在等待条件满足并返回之前重新锁定它。

注意: 

1. 让线程在进行等待的时候,会自动释放锁 。
2. 线程被唤醒的时候,是在临界区内唤醒的,当线程被唤醒, 线程在pthread_cond_wait返回的时候,要重新申请并持有锁。
3. 当线程被唤醒的时候,重新申请并持有锁本质是也要参与锁的竞争的!!

唤醒等待

pthread_cond_broadcast 函数用于唤醒所有等待指定条件变量的线程。

int pthread_cond_broadcast(pthread_cond_t *cond);

pthread_cond_signal 函数用于唤醒一个等待指定条件变量的线程。

int pthread_cond_signal(pthread_cond_t *cond);

简单案例:

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int tickets = 1000;
void *ThreadRoutine(void *args)
{
    std::string name = static_cast<const char *>(args);

    while(true)
    {
        pthread_mutex_lock(&mutex);
        if(tickets > 0)
        {
            std::cout << name<< ", get a ticket: " << tickets-- << std::endl; // 模拟抢票
            usleep(1000);
        }
        else
        {
            std::cout << "没有票了," << name << std::endl; // 就是每一个线程在大量的申请锁和释放锁,浪费锁资源
            usleep(2000);
            // pthread_cond_wait(&cond, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr,ThreadRoutine,(void *)"thread-1");
    pthread_create(&t2, nullptr,ThreadRoutine,(void *)"thread-2");
    pthread_create(&t3, nullptr,ThreadRoutine,(void *)"thread-3");
    
    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
    pthread_join(t3,nullptr);

    return 0;
}

结果:虽然实现了互斥,但是没有票的时候每一个线程在大量的申请锁和释放锁,浪费锁资源。出现忙等现象

使用条件变量后:

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int tickets = 1000;
void *ThreadRoutine(void *args)
{
    std::string name = static_cast<const char *>(args);

    while(true)
    {
        pthread_mutex_lock(&mutex);
        if(tickets > 0)
        {
            std::cout << name<< ", get a ticket: " << tickets-- << std::endl; // 模拟抢票
            usleep(1000);
        }
        else
        {
            std::cout << "没有票了," << name << std::endl; // 就是每一个线程在大量的申请锁和释放锁,浪费锁资源
            pthread_cond_wait(&cond, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr,ThreadRoutine,(void *)"thread-1");
    pthread_create(&t2, nullptr,ThreadRoutine,(void *)"thread-2");
    pthread_create(&t3, nullptr,ThreadRoutine,(void *)"thread-3");

    while (true)
    {
        sleep(7);
        pthread_mutex_lock(&mutex);
        tickets += 1000;
        pthread_mutex_unlock(&mutex);
        // pthread_cond_broadcast(&cond);
        pthread_cond_signal(&cond);
    }
    
    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
    pthread_join(t3,nullptr);

    return 0;
}

为什么 pthread_cond_wait 需要互斥量?

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据

按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:

// 错误的设计

pthread_mutex_lock(&mutex);

while (condition_is_false) {

      pthread_mutex_unlock(&mutex);

      //解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过

      pthread_cond_wait(&cond);

      pthread_mutex_lock(&mutex);

}

pthread_mutex_unlock(&mutex);

  • 由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。
  • int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样

pthread_cond_wait 的工作方式

pthread_cond_wait 函数实际上执行了以下步骤(在内部):

  1. 自动解锁:当线程调用 pthread_cond_wait 时,它会首先自动解锁传入的互斥量。这是为了确保在等待条件变量时,其他线程可以获取该互斥量并修改条件状态。
  2. 阻塞等待:然后,线程阻塞在条件变量上,等待其他线程通过 pthread_cond_signal 或 pthread_cond_broadcast 发送信号。
  3. 重新锁定:当条件变量接收到信号并且线程被唤醒时,pthread_cond_wait 在返回之前会重新锁定之前解锁的互斥量。这是为了确保在检查条件是否满足时,数据仍然受到保护。
  4. 虚假唤醒检查:由于虚假唤醒的可能性,线程在 pthread_cond_wait 返回后通常会重新检查条件是否真正满足。这通常在一个循环中完成,循环的每次迭代都包括调用 pthread_cond_wait。

条件变量正确的使用方式

等待条件代码:

pthread_mutex_lock(&mutex);  
while (!condition_is_true) //条件为假
{  
    pthread_cond_wait(&cond, &mutex); // 自动解锁mutex,等待条件变量,然后重新锁定mutex  
}  
// 现在可以安全地处理条件满足的情况  
pthread_mutex_unlock(&mutex);

给条件发送信号代码:

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

9.4 生产者消费者模型

概念

生产者消费者模型描述了生产者线程(Producer)和消费者线程(Consumer)之间的交互。生产者负责创建和填充数据,而消费者则负责处理这些数据。为了防止生产者和消费者同时访问共享资源(如数据缓冲区或队列),通常需要使用互斥锁和条件变量来确保线程安全。

为了方便记忆和表述,可以理解为“321”原则,即

3种关系:生产者之间竞争-互斥、消费者之间竞争-互斥、生产者和消费者之间互斥和同步。

2种角色:生产者(1 or n)、消费者(1 or n)。(线程或进程)

1个交易场所:内存空间。

为何要使用生产者消费者模型

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

生产者消费者模型的优点

  • 解耦:生产者和消费者之间不直接通信,它们通过一个共享的缓冲区(阻塞队列)进行交互。这样,生产者和消费者之间的耦合度降低,它们可以独立地运行,而不需要了解彼此的实现细节。
  • 支持并发:生产者和消费者可以并发地运行,因为它们不需要等待对方完成操作。
  • 支持忙闲不均:生产者和消费者的处理能力可能不同,有的线程可能比其他线程更忙。生产者消费者模型通过阻塞队列来平衡生产者和消费者的处理能力,确保生产者不会因为缓冲区满而阻塞,也不会有消费者因为缓冲区空而阻塞。

9.5 基于BlockingQueue的生产者消费者模型

BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出。

以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞。

C++ queue模拟阻塞队列的生产消费模型(C++标准库(截至C++20)中并没有直接提供直接阻塞队列。但是,我们可以使用std::queue结合mutex(互斥锁)、cond(条件变量)等同步机制来模拟一个阻塞队列)

BlockQueue.hpp如下:

#pragma once

#include <iostream>
#include <pthread.h>
#include <queue>
#include "LockGuard.hpp"

const int defaultCapacity = 10;

template <class T>
class BlockQueue
{
public:
    BlockQueue(size_t cap = defaultCapacity)
        : _capacity(cap)
    {
        pthread_mutex_init(&_mutex,nullptr);
        pthread_cond_init(&_p_cond,nullptr);
        pthread_cond_init(&_c_cond,nullptr);
    }

    bool IsFull()
    {
        return _q.size() == _capacity;
    }

    bool IsEmpty()
    {
        return _q.size() == 0;
    }

    void Push(const T &in)//生产者
    {
        LockGuard lockguard(&_mutex);
        // pthread_mutex_lock(&_mutex);
        while(IsFull())//使用while,不用if,防止等待后条件改变,具有较强健壮性
        {
            //阻塞等待
            pthread_cond_wait(&_p_cond,&_mutex);
        }
        _q.push(in);
        pthread_cond_signal(&_c_cond);//唤醒消费者,可以在锁里面唤醒,也可以解锁后唤醒。唤醒时如果对方已经被唤醒,那么就自动忽略这条指令
        // pthread_mutex_unlock(&_mutex);
    }

    void Pop(T *out)//消费者,通过out传出数据
    {
        LockGuard lockguard(&_mutex);
        //pthread_mutex_lock(&_mutex);
        while (IsEmpty())
        {
            //阻塞等待
            pthread_cond_wait(&_c_cond, &_mutex);
        }
        *out = _q.front();
        _q.pop();
        pthread_cond_signal(&_p_cond);
       // pthread_mutex_unlock(&_mutex);
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_p_cond);
        pthread_cond_destroy(&_c_cond);
    }

private:
    std::queue<T> _q;
    pthread_mutex_t _mutex;
    pthread_cond_t _p_cond; // 生产者的条件变量
    pthread_cond_t _c_cond; // 消费者的条件变量
    size_t _capacity;// _q.size() == _capacity,满了,不能再生产,_q.size() == 0, 空,不能消费了
};

Main.cc 如下:

#include "LockGuard.hpp"
#include "BlockQueue.hpp"
#include <unistd.h>
#include "Task.hpp"

class ThreadData
{
public:
    BlockQueue<Task> *_bq;
    std::string name;
};

void *consumer(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    while (true)
    {
        Task t;
        // 1. 消费数据 bq->pop(&data);
        td->_bq->Pop(&t);
        // 2. 进行处理
        t();
        std::cout << "consumer data: " << t.PrintResult() << ", " << td->name << std::endl;
    }
    
}

const std::string opers = "+-*/%^&*()";

void *productor(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    while (true)
    {
        // 1. 有数据,从具体场景中来
        int data_x = rand() % 10;
        usleep(rand() % 200); // 防止速度过快结果相同
        int data_y = rand() % 10;
        usleep(rand() % 200);
        char op = opers[rand() % (opers.size())];
        Task t(data_x, data_y, op);
        std::cout << "productor task: " << t.PrintTask() <<  ", " << td->name << std::endl;
        // 2. 进行生产
        td->_bq->Push(t);
        sleep(1);
    }
    return nullptr;
}

int main()
{
    srand((uint16_t)time(nullptr) ^ getpid() ^ pthread_self());
    BlockQueue<Task> *bq = new BlockQueue<Task>();
    pthread_t c[3], p[2]; // 3个消费者,2个生产者

    ThreadData *td1 = new ThreadData();
    td1->_bq = bq;
    td1->name = "consumer - 1";
    pthread_create(&c[0], nullptr, consumer, td1);

    ThreadData *td2 = new ThreadData();
    td2->_bq = bq;
    td2->name = "consumer - 2";
    pthread_create(&c[1], nullptr, consumer, td2);

    ThreadData *td3 = new ThreadData();
    td3->_bq = bq;
    td3->name = "consumer - 3";
    pthread_create(&c[2], nullptr, consumer, td3);

    ThreadData *td4 = new ThreadData();
    td4->_bq = bq;
    td4->name = "productor - 1";
    pthread_create(&p[0], nullptr, productor, td4);

    ThreadData *td5 = new ThreadData();
    td5->_bq = bq;
    td5->name = "productor - 2";
    pthread_create(&p[1], nullptr, productor, td5);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    return 0;
}

LockGuard.hpp如下:

#pragma once

#include <pthread.h>

class Mutex
{
public:
    Mutex(pthread_mutex_t *pmutex)
    :_pmutex(pmutex)
    {}

    void Lock()
    {
        pthread_mutex_lock(_pmutex);
    }

    void UnLock()
    {
        pthread_mutex_unlock(_pmutex);
    }

private:
    pthread_mutex_t *_pmutex;
};

class LockGuard
{
public:
    LockGuard(pthread_mutex_t *pmutex)
    :_mutex(pmutex)
    {
        _mutex.Lock();
    }

    ~LockGuard()
    {
        _mutex.UnLock();
    }
private:
    Mutex _mutex;
};

Task.hpp如下:

#include <iostream>
#include <string>
#include <unistd.h>

const int defaultRet = 0;

enum
{
    ok = 0,
    div_zero,
    mod_zero,
    unknow
};

class Task
{
public:
    Task()
    {}
    Task(int x, int y, char op)
        : _x(x), _y(y), _op(op), _flag(ok), _ret(defaultRet)
    {
    }

    void Run()
    {
        switch (_op)
        {
        case '+':
            _ret = _x + _y;
            break;
        case '-':
            _ret = _x - _y;
            break;
        case '*':
            _ret = _x * _y;
            break;
        case '/':
        {
            if (_y == 0)
                _flag = div_zero;
            else
                _ret = _x / _y;
        }
        break;
        case '%':
        {
            if (_y == 0)
                _flag = mod_zero;
            else
                _ret = _x % _y;
        }
        break;
        default:
            _flag = unknow;
            break;
        }
    }
    void operator()()
    {
        Run();
        sleep(2);
    }

    std::string PrintTask()
    {
        std::string s;
        s = std::to_string(_x);
        s += _op;
        s += std::to_string(_y);
        s += "=?";

        return s;
    }
    std::string PrintResult()
    {
        std::string s;
        s = std::to_string(_x);
        s += _op;
        s += std::to_string(_y);
        s += "=";
        s += std::to_string(_ret);
        s += " [";
        s += std::to_string(_flag);
        s += "]";

        return s;
    }
    ~Task()
    {}

private:
    int _x;
    int _y;
    char _op;
    int _ret;
    int _flag;//为0可信,其余不可信
};

Makefile如下:

testBlockQueue:Main.cc
	g++ -o $@  $^ -lpthread

.PHONY:clean
clean:
	rm -f testBlockQueue

结果如下:

  • 13
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值