Linux——多线程

目录

线程概念

线程控制

线程创建

进程 vs 线程

线程异常

线程等待

线程终止

pthread_cancel

进程替换

线程分离

线程互斥

mutex

mutex接口

mutex的理解

互斥锁的实现

可重入和线程安全

死锁

什么是死锁

死锁产生的必要条件

避免死锁

线程同步

概念

条件变量

条件变量函数


线程概念

        在一个程序加载到内存中时,他就变成了一个进程,会有自己的代码和数据还有内核数据结构,也有虚拟地址到物理地址的映射关系。

        如果我想再创建一个进程,这个进程不想给他创建内核数据结构只有PCB,让这个PCB指向同样的地址空间。就是通过某些手段将当前进程的“资源”通过某些方式划分给不同的PCB。

        我们把上图中的每一个task_struct都可以叫做一个线程(thread)。线程是在进程内部执行的,或者说线程在进程的地址空间内运行,是操作系统调度的基本单位

        上面的这每一个线程都是一个执行流,线程会更轻量化,执行的粒度更细调度轻量化资源占用更少调度成本较低,只要能满足这些它都是线程,不管是在哪个操作系统下。

        进程和线程都要被调度、被创建、维护各种关系等,这两种在概念上都高度重合,那么操作系统也要维护和管理这些线程,就这些操作下来,只是多了一份和进程高度重合的代码来维护线程,所以Linux中没有在内核上去区分进程和线程都用task_struct表示,只不过进程有独立地址空间,线程和进程共享地址空间就行了,用最小的代码实现线程的效果。

        在用户视角看来,进程就是内核数据结构加上进程对应的代码和数据;在内核的视角来看,进程是承担分配系统资源的基本实体

        申请task_struct,申请地址空间,创建页表,开辟内存保存代码和数据,这些都是进程向操作系统申请的,之后的线程就不会再向操作系统申请,而是向进程申请。

        原来我们说的进程是只有一个执行流的进程,后面就可能会遇到内部有多个执行流的进程,原来的task_struct就是进程内部的一个执行流

        在CPU的视角,它不关心是进程还是线程,它只知道task_struct。在Linux下的PCB的量级会小于等于其他操作系统的PCB,如果这个PCB是多线程的一个执行流,那就要比其他OC的PCB量级小,如果只有一个线程,那就是等于。

        所以Linux下的进程统称为轻量级进程

        Linux下没有真正意义上的线程结构,因为没有对应的数据结构,它的进程是用PCB模拟实现的。所以Linux不能直接给我们提供线程的相关接口,只能提供轻量级进程的接口。但是用户有不知道什么是轻量级进程,我只想用进程的时候调用进程的接口,用线程的时候调用线程的接口,所以Linux在用户层实现了一套用户多线程方案,以库的方式提供给用户进行使用,这就是pthread——线程库,也叫他原生线程库


线程控制

线程创建

作用:创建一个新的线程

参数:

  • thread:线程id,是一个输出型参数
  • attr:设置创建线程的属性,默认为nullptr就可以了
  • start_routine:函数指针,线程执行进程代码一部分的入口函数
  • arg:传入入口函数的参数

返回值:创建成功返回0,失败错误码被设置

还有要注意的是,在使用gcc或g++编译的时候要加 -lpthread

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

using namespace std;

void* threadRun(void* args)
{
    const string name = (char*)args;
    while (true)
    {
        cout << "新线程: "<< name << ", pid: " << getpid() << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5]; // 直接创建5个线程
    char name[64];
    for (int i = 0; i < 5; i++)
    {
        snprintf(name, sizeof(name), "thread-%d", i);
        pthread_create(tid + i, nullptr, threadRun, (void*)name); // 创建线程,把name传入函数
        sleep(1);
    }

    while (true) 
    {
        cout << "主线程, pid: " << getpid() << endl;
        sleep(3);
    }

    return 0;
}

        使用ps -aL指令就可以查看系统中的轻量级进程,他们都属于同一个进程,LWP(Light Weight Process)就是轻量级进程,这些编号都是不一样的,有一个PID和LWP是一样的,那就是主线程,所以操作系统识别是LWP,PID不能说明唯一性,原来只有一个轻量级进程的时候LWP和PID是一样的,也没有问题。

进程 vs 线程

        进程中的多个线程是共享同一地址空间的,比如正文代码区、全局数据区和堆区都是共享的,文件描述符表、信号的处理方式、当前工作目录、用户id和所属组id也是共享的。

int g_val = 0;

void* threadRoutine(void* args)
{
    while (true)
    {
        cout << (char*)args << " : " << g_val << " &: " << &g_val << endl;
        sleep(1);
        g_val++;
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    while (true)
    {
        cout << "main thread: " << g_val << " &: " << &g_val << endl;
        sleep(1);
    }

    return 0;
}

        如果使用__thread来修饰全局变量,可以让每一个线程各自拥有一个全局变量 -- 线程的局部存储。

__thread int g_val = 0;

        进程是资源分配的基本单位,而线程是调度的基本单位,也有一部分数据是每一个线程独有的,比如线程id;线程也是要被调度的,寄存器中要存有这个线程的上下文,所以寄存器也是独有的,要调度肯定还有优先级也是独有的;每个线程要调用不同的函数完成各种功能,也定要入栈和出栈,临时变量要保存在栈中,如果栈也共享的,所有的执行流都要访问,另一个执行流可能就会覆盖你的数据,所以栈也是独有的寄存器和栈可以体现出线程的动态属性

线程的优点:

  • 线程之间切换需要操作系统做的工作更少,成本比较低。
  • 线程占用的资源要比进程少,它的资源也是从进程来的。

线程的缺点(对标多进程):

  • 线程间切换并不是没有成本,如果是单核单CPU的情况下创建一个线程是最好的,不会有切换的成本,所以进程不是创建的越多越好。
  • 健壮性会降低,多个线程都是用了全局变量,一个线程修改了就会影响别人。
  • 缺乏访问控制,后续也要有访问控制的方案。
  • 编程难度变高。

当tast_struct要切换的时候,为什么线程要比进程切换的成本低呢?

  1. 如果是同一个进程地址空间和页表不需要切换。
  2. 如果要调度的是另一个进程,就要把上下文、临时数据、页表、地址空间全都要切换
  3. CPU内部是有硬件级别的缓存的(cache),如果一条一条的从内存中读指令,那就会拉低效率,CPU会根据局部性原理预读一些指令。如果进程切换了cache就失效了,新进程来了,只能重新缓存。

线程异常

 还是演示除0错误。

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

using namespace std;

void* threadRoutine(void* args)
{
    while (true)
    {
        cout << (char*)args << " running ..." << endl;
        sleep(1);
        int a = 10;
        a /= 0;
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    while (true)
    {
        cout << "main thread running ..." << endl;
        sleep(1);
    }

    return 0;
}

        虽然这里看到主线程先运行,新线程后运行,其实线程的运行顺序是由调度器决定的。一个线程异常了就可能导致整个进程终止。

线程等待

        通过前几章说过的进程等待,线程也是需要等待的,如果主线程不等待,也会引发类似的僵尸问题,导致内存泄漏。

        线程等待用的就是这个接口。

参数:

  • thread:线程id,与创建不同的是不需要取地址
  • retval:线程退出的退出码。

返回值:成功返回0,失败返回错误码

 

void* threadRoutine(void* args)
{
    int i = 5;
    while (true)
    {
        cout << (char*)args << " running ..., exit after " << i << " seconds" << endl;
        sleep(1);
        if (--i == 0) break;
    }
    cout << "new thread quit" << endl;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    pthread_join(tid, nullptr); // 默认会阻塞等待

    cout << "main thread wait success ... main quit" << endl;

    return 0;
}

        我们已经看到了主线程在等新线程退出,pthead_join还有一个参数是一个void**,新线程执行的函数的返回值是一个void*,所以这第二个参数就是接受函数返回值的。

void* threadRoutine(void* args)
{
    int i = 5;
    while (true)
    {
        cout << (char*)args << " running ..., exit after " << i << " seconds" << endl;
        sleep(1);
        if (--i == 0) break;
    }
    cout << "new thread quit" << endl;

    return (void*)1;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    void* ret = nullptr;

    pthread_join(tid, &ret); // 默认会阻塞等待

    // 因为这个机器下默认就是64位的,所以不能强转成int
    cout << "main thread wait success ... main quit, new thread quit code: " << (long long)ret << endl;

    return 0;
}

        所以通过这个参数,我们不止可以返回一个数,也可以返回从堆上申请的空间,因为堆是共享的。

void* threadRoutine(void* args)
{
    int i = 5;
    int* data = new int[5];
    while (true)
    {
        cout << (char*)args << " running ..., exit after " << i << " seconds" << endl;
        sleep(1);
        data[i-1] = i;
        if (--i == 0) break;
    }
    cout << "new thread quit" << endl;

    return (void*)data;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    int* ret = nullptr;

    pthread_join(tid, (void**)&ret); // 默认会阻塞等待

    cout << "main thread wait success ... main quit" << endl;
    for (int i = 0; i < 5; i++)
    {
        cout << ret[i] << endl;
    }

    return 0;
}

线程终止

原来我们使用的exit是终止进程的,那我们现在想要一个线程终止该怎么做呢?

作用:终止线程

参数:retval返回值

void* threadRoutine(void* args)
{
    int i = 5;
    while (true)
    {
        cout << (char*)args << " running ..., exit after " << i << " seconds" << endl;
        sleep(1);
        if (--i == 0) break;
    }
    cout << "new thread quit" << endl;

    pthread_exit((void*)1);
    
}

pthread_cancel

还有一种终止进程的方法就是取消进程。 

参数:要取消的线程的id。

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

void* threadRoutine(void* args)
{
    while (true)
    {
        cout << (char*)args << " running ..." << endl;
        sleep(1);
    }
    cout << "new thread quit" << endl;

    pthread_exit((void*)1);
    
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    int i = 0;
    while (true)
    {
        cout << "main thread running ..." << endl;
        sleep(1);
        i++;
        if (i == 5) break;
    }

    pthread_cancel(tid);
    cout << "pthread_cancel: " << tid << endl;

    void* ret = nullptr;
    pthread_join(tid, (void**)&ret); // 默认会阻塞等待

    cout << "main thread wait success ... main quit, new thread quit code: " << (long long)ret << endl;

    return 0;
}

        线程被取消,join的退出码会被设置为-1。

        使用pthread_cancel时,取消的一定是一个已经跑起来的线程,我觉得不需要了,才取消的。

        一般都是使用主线程去取消新线程,如果主线程被新线程取消了,那么谁来等待这个新线程退出呢,所以一般不这样做。

        我们也看到了pthread_cancel取消的线程id是很长的一串,并不是我们使用ps aL看到的LWP,它本质上是一个地址,因为我们用的不是Linux自带的接口,而是pthread库提供的接口。        

        一个线程也要有自己的属性,它也要被管理起来,操作系统是对轻量级进程的调度,还有内核数据结构的管理,库也要给用户提供线程相应的属性,Linux下使用进程模拟的线程,用户想要知道一个线程的退出结果,线程的参数、栈结构等,这些内核是不管的,所以就要库在用户层来管理这个线程,就是在库中管理相应的结构。

为了更好的让线程找到自己的用户层属性,就把每个线程结构的起始地址设置为tid

        那么把每个线程的栈结构都放在了共享区,那么地址空间中的栈结构还用不用呢?那肯定是用的,主线程用的就是内核级的栈结构新线程用的就是共享区提供的栈结构,这也不会和单进程一个执行流冲突,一个线程那用的就是内核级的栈区。

 

作用:获取线程的tid

// ...
    cout << (char*)args << " running ..., new tid: " << pthread_self() << endl;
// ...
    cout << "main thread running ..., main tid: " << pthread_self() << endl;
// ...

进程替换

        如果我们使用execl进程替换函数,那么整个进程都要被替换,包括每一个线程,所以不管在哪一个线程下调用execl系列的进程进程替换函数,整个进程都会被替换,所以它才被叫做进程替换。

线程分离

        默认情况下,每一个新建的线程都是要被等待的,如果线程退出不使用pthread_join就无法释放资源,造成内存泄漏。但是pthread_join默认是阻塞等待的,没有非阻塞等待的设置,如果我不关心线程返回的是什么,就可以使用线程分离

参数:线程的tid

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

pthread_detach和pthead_join是不能一起使用的,你都已经分离了,我就不能再等了。

void* threadRoutine(void* args)
{
    pthread_detach(pthread_self());

    while (true)
    {
        cout << (char*)args << " running ..., new tid: " << pthread_self() << endl;
        sleep(1);
        break;
    }

    pthread_exit((void*)1);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");

    while (true)
    {
        cout << "main thread running ..., main tid: " << pthread_self() << endl;
        sleep(1);
        break;
    }

    int n = pthread_join(tid, nullptr);
    cout << "n: " << n << ", " << strerror(n) << endl; 

    return 0;
}

没有线程分离就正常等待。


线程互斥

下面的这些概念在原来的时候也说过,我们现在再来完善一下:

  • 临界资源:多个线程执行流看到的同一份资源叫做临界资源
  • 临界区:每个线程内部访问临界资源的代码就叫做临界区
  • 互斥:为了保护临界区,多执行流任何时刻只能有一个进程进入临界区,这就叫做互斥
  • 原子性:不会被任何调度机制打断,对于一件事要么做要么不做,没有中间状态就成为原子性

下面就来看一些实例,比如我们模拟一个卖票的程序。

int tickets = 1000; // 定义了1000张票,这个数字不重要

void* getTickets(void* args) // 多个执行流可能同时进入这个函数,这个函数就被重入了
{
    (void)args;
    while (true)
    {
        if (tickets > 0)
        {
            usleep(1000);
            cout << pthread_self() << " : " << tickets << endl;
            tickets--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, getTickets, nullptr);
    pthread_create(&t2, nullptr, getTickets, nullptr);
    pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

        我们看到的是票数出现了-1,这就代表着多卖了一张,这可是一个很严重的错误。我们执行的操作tickets--其实做了三步。

        第一步把内存中的数据读到CPU中,第二步tickets--,第三步放回到内存中。线程1在这个操作执行的过程中因为某些原因发生了线程切换,可能线程2已经把tickets减到0了,这时候你这个线程1被换回来,你才减了一次,又把这个数放到了内存中。

        又或者此时的tickets已经到1了,这时候你这个线程1又被切换了,线程2已经把tickets减到0了,这时候线程1回来了,因为刚才判断时tickets大于0,现在又把tickets--,这就变成了-1。

        所以tickets这个全局变量在并发访问的时候导致了数据不一致的问题。

mutex

        为了解决上面的这种问题就要有一个新的概念就是互斥锁。这是原生线程库提供的一个数据类型。

int tickets = 1000;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 这是一个全局的锁

void* getTickets(void* args) // 多个执行流可能同时进入这个函数,这个函数就被重入了
{
    (void)args;
    while (true)
    {
        pthread_mutex_lock(&mtx); // 加锁的同时也要注意解锁,这是一个全局变量
        // 临界区
        if (tickets > 0)
        {
            cout << pthread_self() << " : " << tickets << endl;
            tickets--;
            pthread_mutex_unlock(&mtx);
        }
        else
        {
            pthread_mutex_unlock(&mtx);
            break;
        }
    }
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, getTickets, nullptr);
    pthread_create(&t2, nullptr, getTickets, nullptr);
    pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

        这样我们就看到了tickets变为了1,还可以看到一个现象就是所有的pthread_self打印的tid都是一样的,这是因为选择哪个线程完全是操作系统说了算的,但是我们可以模拟一下买完票的后续操作。

void* getTickets(void* args) // 多个执行流可能同时进入这个函数,这个函数就被重入了
{
    (void)args;
    while (true)
    {
        pthread_mutex_lock(&mtx); // 加锁的同时也要注意解锁,这是一个全局变量
        // 临界区
        if (tickets > 0)
        {
            cout << pthread_self() << " : " << tickets << endl;
            tickets--;
            pthread_mutex_unlock(&mtx);
        }
        else
        {
            pthread_mutex_unlock(&mtx);
            break;
        }
        // 模拟后续动作
        usleep(1000);
    }
}

【注意】:加锁的粒度越小越好,尽量不在加锁和解锁中间放一些无关的代码。

mutex接口

我们再来看一下这些函数。

作用:初始化互斥锁

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

参数:

  • mutex:需要初始化的互斥锁。
  • attr:初始化互斥锁的属性,一般设置为nullptr

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

上面的代码我们使用的:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

调用pthread_mutex_init函数初始化互斥锁叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥锁,该方式叫做静态分配,这个互斥锁不需要销毁。

 

作用:销毁互斥锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:要销毁的互斥锁

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

 

作用:给临界资源加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数:要使用的互斥锁

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

 

作用:给临界资源解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:要使用的互斥锁

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

下面我们就写一个完整的代码:

int tickets = 1000;

#define THREAD_NUM 3

class ThreadData
{
public:
    ThreadData(const string& s, pthread_mutex_t* pm)
        :tname(s)
        ,pmtx(pm)
    {}
    string tname;
    pthread_mutex_t* pmtx;
};

void* getTickets(void* args) // 多个执行流可能同时进入这个函数,这个函数就被重入了
{
    ThreadData* td = (ThreadData*)args;
    (void)args;
    while (true)
    {
        pthread_mutex_lock(td->pmtx);
        // 临界区
        if (tickets > 0)
        {
            cout << td->tname << " : " << tickets << endl;
            tickets--;
            pthread_mutex_unlock(td->pmtx);
        }
        else
        {
            pthread_mutex_unlock(td->pmtx);
            break;
        }
        // 模拟后续动作
        usleep(1000);
        delete td;
    }
}

int main()
{
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);

    pthread_t t[THREAD_NUM];
    for (int i = 0; i < THREAD_NUM; i++)
    {
        string name = "thread ";
        name += to_string(i + 1);

        ThreadData* td = new ThreadData(name, &mtx);
        pthread_create(t + i, nullptr, getTickets, (void*)td);
    }

    for (int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
    }

    pthread_mutex_destroy(&mtx);

    return 0;
}

mutex的理解

        如果给临界区加锁后,那么线程在临街中是否会切换。那是一定会切换的,因为切不切换是操作系统说了算的。既然它还要切换,会不会出现上述的问题呢?

        虽然被切换了,在访问tickets的时候,这个线程是持有锁的。其他线程想要访问,那就得先申请锁申请锁也不会成功,所以就被阻塞了,这就保证了临界区数据一致性,所以访问临界资源先申请锁,用完后释放锁,这才是正确的编码方式。

        所以对这个持有锁的线程,其他线程就认为这个线程的操作是原子的

        加锁之后就可以保证临界区的代码一定是串行的

        换言之,每个进程都要申请同一个锁,这个锁不也就是共享资源吗,所以也要保证锁的安全,那申请锁和释放锁也必须是原子的。

互斥锁的实现

        从汇编的角度来说,要是只有一条汇编语句,我们就认为该汇编语句执行时是原子的。在汇编语言中有一条swap或exchange指令,用这一条语句将CPU内的寄存器和内存数据进行交换。

        多执行流中,在CPU中所有的寄存器存放的是当前执行流的上下文,这些数据是该执行流私有的,但寄存器的空间是被所有执行流共享的。


可重入和线程安全

  • 现在就可以理解什么是重入了,抢票的函数就是一个可重入的函数
  • 线程安全:多个线程并发访问同一段代码时不会出现不同的结果。

常见的线程不安全情况:

  • 不保护临界资源的函数。

  • 函数状态随着调用发生了变化的函数。

  • 返回指向静态变量指针的函数。

  • 调用线程不安全的函数。

常见的线程安全的情况:

  • 每个线程对全局变量或者静态变量只有读取的权限。
  • 类或者接口对于线程来说都是原子操作,就像只用一条语句完成交换。

常见不可重入的情况:

  • 调用了malloc/free,因为malloc函数是用全局链表来管理堆的。
  • 调用标准IO库函数,函数大多都是不可重入的方式使用全局数据结构。
  • 可重入函数内使用了静态的数据结构。

常见可重入的情况:

  • 不使用全局变量或静态变量。
  • 不使用malloc或者new开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都由函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据,一开始定义一个临时变量保存数据,函数执行完后再用临时数据恢复。

可重入和线程安全的联系:

  • 函数可重入,那么线程一定是安全的。
  • 函数不可重入,那么就有可能引发线程安全问题。

可重入函数和线程安全的区别:

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

死锁

什么是死锁

        死锁是指在一组执行流中的各个执行流均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态

        别说多个了,一个执行流如果重复申请锁,也会产生死锁,你已经申请了一个锁了,在申请一看al是0,那直接就被阻塞了,但是你还拿着锁呢,所以写代码的时候也要注意。

        多执行流就像下图,锁执行线程表示分配给了他,线程指向锁代表要申请锁,此时线程a持有锁1,线程b持有锁2,线程a还想要锁2,线程b还想要锁1,那这两个线程就都阻塞了,这就叫做死锁。

死锁产生的必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用,使用了互斥锁
  • 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放,我已经有了一个锁了,还想再申请另一个锁。
  • 不可抢占条件: 一个执行流已获得的资源,在未使用完之前,不能强行夺取。
  • 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系,最后形成一个环。

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

线程同步

概念

  • 同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
  • 竞态条件: 因为时序问题,而导致程序异常,称之为竞态条件。

        加锁也是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后啥也不做,所以这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题,他可以这样做,就是不合理。
        加锁没有错,它能够保证在同一时间段只有一个线程进入临界区,但没有高效的使用这份临界资源。
        现在就规定,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。

        所以多执行流按照一定的顺序进行对临街资源的访问就叫做线程同步,引入线程同步就是为了解决访问临界资源不合理的问题。

条件变量

        当我们申请临界资源的时候,先要对临界资源是否存在做检测,要检测也是要访问临界资源。所以对临街资源的检测也要在加锁和解锁之间,这就导致了频繁的检测就绪条件,也就要频繁的申请释放锁,为了解决这个问题提出来一下建议:

  1. 不要让线程频繁的检测,要让他等待
  2. 当条件就绪的时候,通知对应的线程,让他进行资源申请和访问。

这就引出了一个概念:条件变量

条件变量函数

这些函数返回值类型都是int,成功返回0,失败返回错误码。

作用:动态分配初始化条件变量。

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

参数:

  • cond:需要初始化的条件变量。
  • attr:初始化条件变量的属性,一般设置为nullptr。

作用:静态分配初始化条件变量,不需要销毁。

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

作用:销毁条件变量。

int pthread_cond_destroy(pthread_cond_t *cond);

参数:cond:需要销毁的条件变量。

作用:等待条件变量满足

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

参数:

  • cond:需要等待的条件变量。
  • mutex:当前临界区对应的互斥锁

        再来说一下这个第二个参数为什么要传入锁,我们在访问临界资源的时候一定是要先检测,如果条件不满足再使用pthread_cond_wait,检测本身也是在访问临界资源,所以都是先加锁,再检测,如果要是检测出条件不满足就要阻塞这个线程,这时是带着锁阻塞的,那么其他线程想要申请锁就不会申请成功,所以这个函数的第二个参数要传入锁,调用的时候就会释放这个锁,别的线程就可以申请锁了。

        之后当线程被唤醒的时候也会自动帮我们获取锁

作用:唤醒等待

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
  • pthread_cond_signal函数用于唤醒等待队列中首个线程。
  • pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。

参数:唤醒在cond条件变量下等待的线程。

        下面就简单的演示一下条件变量是怎么用的,但是没有对应的场景还是无法更好的理解,而且使用条件变量的时候是有问题的,后面会有场景的。

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

using namespace std;

#define TNUM 4

typedef void(*func_t)(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond);

volatile bool quit = false; // 退出条件

class ThreadData
{
public: 
    ThreadData(const string& name, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
        :_name(name)
        ,_func(func)
        ,_pmtx(pmtx)
        ,_pcond(pcond)
    {}
public:
    string _name;           // 线程名
    func_t _func;           // 线程要执行的函数
    pthread_mutex_t* _pmtx; // 互斥锁
    pthread_cond_t* _pcond; // 条件变量
};

void func1(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while (!quit)
    {   
        // 运行到wait的时候,当前线程会立即被阻塞,每个线程都要这样做
        pthread_mutex_lock(pmtx);
        // 这里的等待就是在检测资源是否就绪,它应该在加锁与解锁之间的
        pthread_cond_wait(pcond, pmtx);
        cout << name << " running..." << endl;
        pthread_mutex_unlock(pmtx);
    }
}

void func2(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while (!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx);
        cout << name << " running..." << endl;
        pthread_mutex_unlock(pmtx);
    }
}

void func3(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while (!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx);
        cout << name << " running..." << endl;
        pthread_mutex_unlock(pmtx);
    }
}

void func4(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while (!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx);
        cout << name << " running..." << endl;
        pthread_mutex_unlock(pmtx);
    }
}

void* Entry(void* args)
{
    ThreadData* td = (ThreadData*)args; // td在每一个线程私有的栈结构中保存
    td->_func(td->_name, td->_pmtx, td->_pcond);
    delete td; // 当函数执行完后也要释放td,因为它也是new出来的
}

int main()
{
    pthread_mutex_t mtx; // 互斥锁
    pthread_cond_t cond; // 条件变量

    // 初始化
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cond, nullptr);

    pthread_t tids[TNUM]; // 创建多线程
    func_t funcs[TNUM] = {func1, func2, func3, func4}; // 每个线程独有的方法
    for (int i = 0; i < TNUM; i++)
    {
        string name = "Thread ";
        name += to_string(i + 1);
        ThreadData* td = new ThreadData(name, funcs[i], &mtx, &cond);
        pthread_create(tids + i, nullptr, Entry, (void*)td);
    }

    // 让主线程区一个一个唤醒
    int cnt = 10;
    while (cnt--)
    {
        sleep(1);
        cout << "resume thread run code: " << endl;
        pthread_cond_signal(&cond);
        // pthread_cond_broadcast(&cond); // 唤醒所有等待的线程
    }

    cout << "ctrl done" << endl;
    quit = true;
    pthread_cond_broadcast(&cond); // 再唤醒所有线程,检测退出条件

    // 等待线程退出
    for (int i = 0; i < TNUM; i++)
    {
        pthread_join(tids[i], nullptr);
        cout << "thread: " << tids[i] << " quit" << endl;
    }

    // 销毁
    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);

    return 0;
}

  • 20
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Linux中的多线程实际上是通过进程来模拟实现的。在Linux中,多个线程是通过共享父进程的资源来实现的,而不是像其他操作系统那样拥有自己独立的线程管理模块。因此,在Linux中所谓的“线程”其实是通过克隆父进程的资源而形成的“线程”。这也是为什么在Linux中所说的“线程”概念需要加上引号的原因。 对于Linux中的线程,需要使用线程库来进行管理。具体来说,Linux中的线程ID(pthread_t类型)实质上是进程地址空间上的一个地址。因此,要管理这些线程,需要在线程库中进行描述和组织。 由于Linux中没有真正意义上的线程,因此线程的管理和调度都是由线程库来完成的。线程库负责创建线程、终止线程、调度线程、切换线程,以及为线程分配资源、释放资源和回收资源等任务。需要注意的是,线程的具体实现取决于Linux的实现,目前Linux使用的是NPTL(Native POSIX Thread Library)。 总结来说,Linux中的多线程是通过进程来模拟实现的,线程共享父进程的资源。线程的管理和调度由线程库完成。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [Linux —— 多线程](https://blog.csdn.net/sjsjnsjnn/article/details/126062127)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

微yu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值