Linux:深入解析Linux线程原理与实现

目录

1.线程概念

线程理解

重谈地址空间

Linux进程VS线程

2.线程控制

如何给线程传参:

获取线程的返回值,线程等待

进程分离:

自己简单封装一下语言的线程库:

3.线程互斥

4.加锁

互斥量(锁)的接口:

LockGuard.hpp:

5.线程加锁的本质

6.可重入VS线程安全


1.线程概念

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是"一个进程内部的控制序列"
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
  • 线程是CPU调度的基本单位,进程是承担系统资源的基本实体。

地址空间是进程的"资源"窗口。创建多个同时指向同一个地址空间的PCB ,代码和数据该区分的区分,该共享的共享,即使用页表划分,这些PCB被称为线程,与创建进程相比,创建线程的量级更低,不用地址空间,代码数据,动态库,这里只需要一个PCB,只是参与了进程资源的分配。

  1. 进程的创建简单,释放简单(创建/删除一个PCB)
  2. 线程在进程的地址空间中运行

系统中线程是要比进程多的,要管理这些线程,就要先描述后组织,TCB(Thread),TCB链接到PCB中,TCP的结构,状态,优先级我们可以直接复用PCB,不用再次设计,即多个PCB指向同一个地址空间,这是Linux中的线程实现方案。而我们学习的操作系统课本只规定了线程的特征,并没有规定具体的实现。而Windows创建PCB和TCB的接口是不一样的,Linux相同更简单。

CPU是不需要区分PCB是进程还是线程的,只需要执行代码,CPU拿到的执行流是 <= 进程的,进程至少有个执行线程。进程 = 内核数据结构 + 代码和数据,包含多个执行流的线程PCB,地址空间,页表。

Linux并不存在真正意义上的线程,只是用进程的数据结构模拟线程,地址是代码的一部分,数据的一部分,称为轻量级进程(线程),一个执行流,如果一个进程只有一个执行流,CPU依然认为他是一个轻量级进程,因为进程要有多个执行流。

线程理解

创建线程的函数

第一个参数是输出型参数,返回创建的 tid 编号,第二个是要设置的属性,我们先设为 nullptr ,第3个是函数指针,即线程要执行任务的函数入口,第四个参数就是第三个参数回调函数的参数。这里我们只做简单的验证。成功返回 0,失败返回错误码。

cpp 复制代码
#include <iostream>
#include <pthread.h> 
#include <unistd.h>
#include <sys/types.h>
//新线程
void * ThreadRoutine(void* arg)
{
    const char* threadname = (const char *)arg;
    while(true)
    {
        std::cout << "I am a new thread:" << threadname <<",pid:" << getpid() << std::endl;
        sleep(1);
    }
}

int main()
{
    //linux中一般使用库创建进程,c++11与这也有关
    pthread_t tid;
    //已经由进程了,这里创建线程。
    pthread_create(&tid,nullptr,ThreadRoutine,(void*)"thread 1");
    //主线程
    while(true)
    {
        std::cout << "I am main thread,pid:" << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

当前 GCC 版本高,自动隐式链接 pthread,但这是非标准、不可靠 的特性。可以发现两个死循环都在跑,两个线程的pid是相同的,我们使用 ps -aL 命令查看所有(all)轻量级进程(LWP = Light Weight Process),可以看到两个 testThread 线程,而他们的LWP是不同的,所有CPU调度的时候是看的LWP,不是PIDPID = LWP是主线程

下面多个线程用的是同一份代码,可重入函数:

进程地址空间的内容在线程之间大部分都是共享的:

cpp 复制代码
int gcnt = 100;

void * ThreadRoutine(void* arg)
{
    const char* threadname = (const char *)arg;
    while(true)
    {
        std::cout << "I am a new thread:" << threadname <<",pid:" << getpid() 
            << ",gcnt:" << gcnt << ",&gcnt" << &gcnt << std::endl;
        gcnt--;
        sleep(1);
    }
}

int main()
{
    //linux中一般使用库创建进程,c++11与这也有关
    pthread_t tid;
    //已经由进程了,这里创建线程。
    pthread_create(&tid,nullptr,ThreadRoutine,(void*)"thread 1");

    //主线程
    while(true)
    {
        std::cout << "I am main thread,pid:" << getpid() 
            << ",gcnt:" << gcnt << ",&gcnt" << &gcnt << std::endl;
        sleep(1);
    }

    return 0;
}

可以发现地址和内容都是一样的,所以说线程间通信的成本很低,因为线程共享资源很容易,通信更方便

为什么线程切换轻量化:线程间切换时,需要把寄存器中产生的一些临时数据切换掉就行,不需要切换所有寄存器 ,而进程需要,这是原因之一,主要原因是 缓存 cache使用局部性原理,热数据,给预加载机制提供理论基础线程间切换不需要重新加载cache

时间片也要被内部的线程瓜分,进程的时间片是一定的,时间片也是资源。(防止线程多就分到的时间多,是不公平的)。这时就不存在进程线程区分了,所有的都是轻量级进程。单纯一个PCB描述的就是一个轻量级进程,就是一个执行流。

重谈地址空间

虚拟地址 -> 物理地址

文件系统IO的基本单位大小4KB(page size),磁盘和内存文件加载的单位相同,磁盘4KB称为页帧,内存中称为页框,基本单位的意义就是及时修改1bit也要更新4KB到磁盘。内存要被分为一个个4KB页框,操作系统需要进行管理知道它的使用情况,还是先描述后组织,struct page{ },所以对内存进行管理,struct page pagesn,就变成了对数组的管理。

所谓的文件缓冲区就是将内存页中属于这个文件的一个个内存页和对应的文件对象产生对应的关系就可以,即与 struct page pages 建立联系。

进程地址空间4GB,就是2^32个,如果每个地址都要在页表中有对应关系,没对对应关系假设4B+4B+2B(标志位)=10B,页表在物理内存中都存不下,其实页表是这么设计的,只需要 2^20个页表项即可:

页目录和页框是帮助我们找到页框的,页目录是必须要记载到内存的,页表可以在使用的时候再重外存发生缺页中断再加载到内存 ,所以说页表的个数不需要那么多。进程在执行时CPU会有寄存器保存当前进程的页目录的起始地址 ,虚拟地址到物理地址的转换完全是在**CPU内部MMU中完成的,**然后根据总线访问物理内存。线程划分进程资源本质就是在划分页表,划分页表(一级页目录)的本质就是划分地址空间。

数据类型的意义就是可以让编译器判断偏移量,生成汇编时判断用多大的寄存器读取。

线程的优点:

  • 创建,调度,释放量级低
  • 能充分利用多处理器的可并行数量
  • 计算密集型进程,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/O密集型进程,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

线程的缺点:

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

Linux进程VS线程

进程和线程:

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

线程是调度的基本单位

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

  • 线程ID
  • 一组寄存器(上下文,因为要被调度切换)
  • 栈(调用函数的栈结构)
  • errno
  • 信号屏蔽字
  • 调度优先级

进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

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

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

2.线程控制

Linux 下没有真正的线程,只有轻量级进程的概念,所以,操作系统只会提供轻量级进程创建的系统调用 ,不用直接提供线程创建的接口,Linux 的线程解决方案是在用户空间和内核之间封装了一层 pthread 原生(不残缺,必有)线程库,对上提供线程的控制接口。所以 Linux 的线程是通过库实现的,是独立的第三方库,不属于C/C++,所以需要带编译选项 -lpthread。

如何给线程传参:

void * 可以传对象的地址,我们就可以通过创建一个对象实现给线程的传参:

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

using func_t = std::function<void()>; // typedef std::function<void()> func_t;
const int threadnum = 5;

class ThreadDate
{
public:
    ThreadDate(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 *ThreadRotine(void *args)
{
    ThreadDate *td = static_cast<ThreadDate *>(args);
    while (true)
    {
        std::cout << "new thread thread name:" << td->threadname
                  << ",create time:" << td->createtime << std::endl;
        td->func();
        sleep(1);
    }
}
// 主线程
int main()
{
    std::vector<pthread_t> pthreads;
    for (int i = 0; i < threadnum; i++)
    {
        char threadname[64];
        snprintf(threadname,sizeof(threadname),"%s-%u","thread",i+1);//第二个参数限制写入长度,会自动加终止符\0 

        pthread_t tid;
        ThreadDate *td = new ThreadDate(threadname, (uint64_t)time(nullptr), Print);
        pthread_create(&tid, nullptr, ThreadRotine, (void *)td);
        pthreads.push_back(tid);
        sleep(1);
    }

    std::cout << "thread id:";
    for(auto &tid:pthreads)
    {
        std::cout << tid << ",";
    }
    std::cout << std::endl;

    while (true)
    {
        std::cout << "main thread" << std::endl;
        sleep(1);
    }
    return 0;
}

可以看到这里通过循环创建了多线程,一个主线程,5个新线程。线程 id 记录在 vector 中,发现与 LWD 不同,这个我们后面会讲

研究两个问题:

1.线程的健壮性问题:一个线程崩溃就是一个进程收到了信号,进程退出,所有线程退出。

2.线程id是什么:

获取自身线程ID,我们把它转为 16 进制:

cpp 复制代码
std::string ToHex(pthread_t tid)
{
    char id[64];
    snprintf(id,sizeof(id),"0x%lx",tid);
    return id;
}

void* threadRoution(void* arg)
{
    std::string name = static_cast<const char*>(arg);
    while(true)
    {
        std::cout << "new thread is runing,thread name:" << name 
            << "thread id: " << ToHex(pthread_self()) << std::endl;
        sleep(1);
    }
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoution,(void *)"thread -1");

    while(true)
    {
        std::cout << "main thread,sub thread:" << tid <<
            "main thread id:" << ToHex(pthread_self()) << std::endl;
        sleep(1);
    }

    return 0;
}

看起来像地址,thread ID本质其实是一个地址

获取线程的返回值,线程等待

线程终止:

  1. 线程中return void*
  2. 使用 pthread_exit(void*),而使用exit( )是让进程终止

主线程回收新线程:

成功返回0,失败返回错误码。线程调用函数的返回值和上面 pthread_exit 的返回值都是 void*,pthread_join 的第二个参数式 void **。如果我们要得到新线程的返回值,我们得到的也应该式void*,为了得到void*,需要传入一个void**。 我们可以以结构化的方式返回:

cpp 复制代码
std::string ToHex(pthread_t tid)
{
    char id[64];
    snprintf(id,sizeof(id),"0x%lx",tid);
    return id;
}
class ThreadReturn
{
public:
    ThreadReturn(pthread_t id,std::string info,int code)
        :_id(id),_info(info),_code(code)
    {}
public:
    pthread_t _id;
    std::string _info;
    int _code;
};

void* threadRoution(void* arg)
{
    std::string name = static_cast<const char*>(arg);
    int cnt = 5;
    while(cnt--)
    {
        std::cout << "new thread is runing,thread name:" << name 
            << "thread id: " << ToHex(pthread_self()) << std::endl;
        sleep(1);
    }
    //return nullptrll; //1.线程终止
    //exit(13);//进程终止
    //pthread_exit(nullptr);//退出线程
    ThreadReturn* ret = new ThreadReturn(pthread_self(),"thread quit normal",10);
    return (void*)ret;  
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoution,(void *)"thread -1");
    std::cout << "main thread,sub thread:" << tid << "main thread id:" << ToHex(pthread_self()) << std::endl;
    //线程退出也要被主线程等待
    //1.线程退出,没有等待,也会出现类似进程的僵尸进程。
    //2.线程退出时,主线程如何获取新线程的返回值!
    //线程等待
    void* ret = nullptr;//void 不能定义对象,大小不确定,void* 是可以的,因为是指针
    int n = pthread_join(tid,&ret);//阻塞式等待,ret输出型参数。
    std::cout << "main thread done" << "n:" << n << std::endl;
    ThreadReturn *r = static_cast<ThreadReturn*>(ret);
    std::cout << "main thread get new thread return,id:" << ToHex(r->_id) << std::endl
        << "info:" << r->_info << std::endl
        << "code:" << r->_code << std::endl;
    delete r;
    return 0;
}

进程等待时 wait 和 waitpid 都有一个输出型参数记录,记录退出信息,而线程这里不用,因为出异常后整个进程会挂掉。

为什么需要线程等待?

  1. 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  2. 创建新的线程不会复用刚才退出线程的地址空间

如果新线程一直不退出,主线程如果等待的话需要一直阻塞,不等的话会出现僵尸进程的情况 。我们可以将线程设置为分离状态,默认线程模式是 joinable,就是必须要等待。

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

调用函数 pthread_join() 的线程将挂起等待,直到目标 tid 的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  1. 如果 thread 线程通过 return返回,value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。即return 值。
  2. 如果thread线程被别的线程调用 pthread_ cancel 异常终掉(取消),value_ ptr所指向的单元里存放的是常数 PTHREAD_ CANCELED 即 -1。
  3. 如果 thread 线程是自己调用 pthread_exit 终止的,value_ptr 所指向的单元存放的是传给pthread_exit 的参数。即 exit 值。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
  5. 进程分离时不需要关心返回值。

返回 -1 的情况:

cpp 复制代码
void* threadRoution(void* arg)
{
    //pthread_detach(pthread_self());//自己将自己设为分离状态
    while(true)
    {
        std::cout << "thread is running,thread id: " << ToHex(pthread_self()) << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoution,(void *)"thread -1");
    sleep(5);
    
    //pthread_detach(tid);//分离

    int n = pthread_cancel(tid);//线程取消
    std::cout << "main thread cancel done," << "n:" << n << std::endl;

    void* ret = nullptr;
    n = pthread_join(tid,&ret);
    std::cout << "main thread done," << "n:" << n 
        << ",thread return :" << (int64_t)ret << std::endl;//64位地址需要长整型

    return 0;
}

如果被分离的话就没有意义了,ret的值是0。

进程分离:

在新线程内自己调用 pthread_detach(pthread_self()) 分离,位置随意:

cpp 复制代码
void* threadRoution(void* arg)
{
    pthread_detach(pthread_self());//自己将自己设为分离状态
    int cnt = 5;
    while(cnt--)
    {
        std::cout << "thread is running,thread id: " << ToHex(pthread_self()) << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoution,(void *)"thread -1");

    sleep(1);
    int n = pthread_join(tid,nullptr);
    std::cout << "main thread done" 
            << "n:" << n << std::endl;

    return 0;
}

也可以在主进程中调用,对新进程分离。

cpp 复制代码
phtread_detach(tid)

分离之后再去 pthread_join() 等待就会出错。不分离就会正常等待。

取消一个线程:

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

线程库的作用:

我们用到的关于线程控制控制的结构都不是系统直接提供的接口,而是原生线程库pthread 提供的接口,Linux并不存在真线程,是进程模拟线程。不会直接提供任何创建线程的系统调用,最多直接提供创建轻量级进程的系统调用。所以在用户和系统之间添加了一层软件层 pthread 库,所以Linux是通过 pthread 库解决线程的,是原生的。

用户想要知道线程的状态 ,所以 pthread 也需要对线程(轻量级进程)进行管理,struct tcb{ },它并不在系统中呈现,而是在库中呈现,tcb中的属性,有的通过LWP来获取,有的通过用户获取,库中的 tcb 与 系统中的 pcb 一一对应。

线程要有独立性,最重要的有两个,第一个是上下文(寄存器) ,系统以轻量级进程的方式帮我们维护在PCB中,栈区在进程地址空间中只有一个(维护栈的寄存器只有一套),库在共享区,栈需要给多个线程分配,需要安排好。堆可以根据不同的虚拟地址解决。轻量级进程的创建,系统调用man 2 clone,也是 fork 的底层,上面那些函数都是man 3,

第一个参数表示创建出的执行流要执行的回调方法,也就是我们之前要线程执行的自定义函数;第二个参数允许用户传入栈空间的地址;flags 表示创建一个子进程还是轻量级进程;第四个是参数,后面是注释。我们创建线程时调用的 pthread_create,实际底层调用的是 clone,并且 pthread 库会 new 一段堆空间,然后将地址传进来充当栈,每个新线程的栈在库在维护。默认地址空间中的栈由主线程使用,新线程中的栈在默认地址空间的堆区。

如何理解进程pthread库来管理线程:

pthread 需要被加载到内存,在进程地址空间的共享区,也可以映射到其他的进程中,即 pthread 库是共享库,他要管理系统中所有用户创建的线程。所以称为用户级线程,不在内核中。

线程id是什么:

库方便我们快速地找到每个线程,pthread_t tid 就是线程属性集合在库中的地址!!!那我们取消线程 的时候就是根据这个地址找到这个线程属性中的LWP ,然后将这个轻量级进程取消。当然主线程也有线程 tid,主线程等待新线程 时,可以根据 pthread_join(pthread_t tid) 中的tid 找到线程属性集合读取返回值,线程分离也就是根据地址(线程id,tid)改变线程属性joinable。

LWP 是内核的概念,线程id是库封装好的概念

语言角度理解 pthread:

语言上的多线程,本质用的都是原生线程库,编译时需要 -lpthread。语言的线程相关接口在Linux 环境用Linux的原生线程库,Windows环境下用Windows线程库,可以实现语言的可移植性

**线程局部属性:**全局变量,本身就是被所有线程共享的

cpp 复制代码
//int g_val = 100;
__thread int g_val = 100;//局部存储 

链接的时候会将 __thread 修饰的变量在每个线程的局部存储区域都开辟一份,地址不同。但是 __thead只能存储内置类型,容器是不可以的,因为线程局部存储是编译期间完成的。

cpp 复制代码
int getlwp()
{
    return syscall(SYS_gettid);
}

void* threadRoutine(void* args)
{    
    std::string name = static_cast<const char*>(args);
    lwp = getlwp();//调用系统调用获取当前进程的 TID
    while (true)
    {
        sleep(1);
        std::cout << name << ",g_val:" << g_val 
            << ",&g_val:" << &g_val  
            << ",lwp" << lwp << "\n" << std::endl;
        g_val++;
    }
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoutine,(void*)"thread1");
    lwp = getlwp();
    while (true)
    {
        sleep(1);
        std::cout << "main,g_val:" << g_val 
            << ",&g_val:" << &g_val 
            << ",lwp" << lwp << "\n" << std::endl;
    }
    pthread_join(tid,nullptr);

    return 0;
}

自己简单封装一下语言的线程库:

类内方法,第一个参数是this*,我们需要改成 static 静态函数,然后传入具体对象的 this 指针。当然也可以将函数放在类外,调用时确保访问的类内成员变量是 public 的。

我们将线程对象化了,我们就可以通过各种容器进行管理,这就是先描述后组织

cpp 复制代码
#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(func_t<T> func,const std::string &threadname,T data)
        :_tid(0),_threadname(threadname),_isrunning(false),
        _func(func),_data(data){}

    //线程运行回调
    static void* ThreadRontine(void* args)//类内方法,第一个参数是this*
    {
        //(void)args;//仅仅防止编译器报警
        Thread<T>* ts = static_cast<Thread<T>*>(args);
        //Thread* ts = static_cast<Thread*>(args);
        ts->_func(ts->_data);
        return nullptr;
    }

    bool Start()//线程开始
    {
        int n = pthread_create(&_tid, nullptr, ThreadRontine, this);
        if (n == 0)
        {
            _isrunning = true;
            return true;
        }
        else
            return false;
    }

    bool Join()
    {
        if (_isrunning)
        {
            int n = pthread_join(_tid, nullptr);
            if(n == 0)
            {
                _isrunning = false;
                return false;
            }
            else
                return true;
        }
        else
            return true;
        
    }
    std::string ThreadName() const {return _threadname;}
    bool IsRunning(){return _isrunning;}
    ~Thread(){}

private:
    pthread_t _tid;
    std::string _threadname;
    bool _isrunning;
    func_t<T> _func;
    T _data;
};

在 Thread<T> 类的成员函数(包括静态成员函数)内部,类名 Thread 会被编译器自动解释为当前实例化类型 Thread<T> ,这是 C++ 的"注入类名"(injected-class-name)规则,Thread<T>* ts = static_cast<Thread<T>*>(args);可以简写为Thread* ts = static_cast<Thread*>(args);。

main.cc:

cpp 复制代码
#include <iostream>
#include "Thread.hpp"
#include <vector>
#include <unistd.h>

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

void Print(int num)
{
    while(num--)
    {
        std::cout << "hello" << std::endl;
        sleep(1);
    }
}

int main()
{
    Thread<int> t(Print,GetThreadName(),10);
    t.Start();
    t.Join();
    return 0;
}

3.线程互斥

我们使用上面我们写的库做下面的实验,多执行流重入 GetTicket :

cpp 复制代码
int ticket = 10000;//总票数
void GetTicket(std::string name)
{
    while(true)
    {
        if(ticket>0)
        {
            usleep(1000);//充当抢票时间
            printf("%s get a ticket: %d\n",name.c_str(),ticket);
            ticket--;
        }
        else
            break;
        //实际情况还有后续动作,比与数据库
    }
}

int main()
{
    std::string name1 = GetThreadName();//多执行流重入
    Thread<std::string> t1(GetTicket,name1,name1);
    std::string name2 = GetThreadName();
    Thread<std::string> t2(GetTicket,name2,name2);
    std::string name3 = GetThreadName();
    Thread<std::string> t3(GetTicket,name3,name3);
    std::string name4 = GetThreadName();
    Thread<std::string> t4(GetTicket,name4,name4);

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

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

可以发现有很多不对的地方,比如多个执行流得到同一张票,还有票号为 0和 负数。

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

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

C/C++ 语言,数据在内存,计算在CPU,比如说 cnt++;最后会变成三条汇编语言,我们认为一条汇编语句就是一次原子性操作,第一步数据 mov 到 CPU 寄存器,第二部计算,第三步 mov 回去

判断本身也是计算,CPU一般要做四种工作,算数运算,逻辑判断,处理内外中断,控制单位。判断本身不是原子的,就可能发生一个执行流读到 ticket 为1,然后时间片切换,其他执行流也读到1,都通过了判断,之后再对ticket减减,ticket又需要从内存重新加载,多个执行流减减,所以最后可能成了负数。

数据在内存中,本质是被线程共享的。数据被读到寄存器中,本质变成了线程的上下文,属于线程的私有数据。

4.加锁

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

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

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

互斥量(锁)的接口:

锁需要初始化,有两种方法:

方法1,静态 static(全局)分配

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

方法2:动态(局部)分配,当然也可以这样定义全局锁

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);

参数:

mutex:要初始化的互斥量(锁)

attr:NULL,锁的属性

加锁和解锁,返回值:成功返回0,失败返回错误号:

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  1. 我们要尽可能的给少的代码块加锁。因为加锁后临界区只有有一个执行流访问,如果加太多多线程的意义也就没有了,都变成了串行。
  2. 一般加锁都是给临界区加锁
  3. 全局分配的锁是共享资源,申请锁本身是安全的,原子的,原理下面讲。加锁的规则是一旦有了一个需要加锁访问的公共资源,所有访问的都必须加锁。
  4. 根据互斥的定义,任何时刻,只允许一个线程申请锁成功!其他线程申请锁失败,失败的线程怎么办?在 mutex 上进行阻塞(本质是等待),等别人把锁释放掉(变量名相同的锁),即访问完临界区。
  5. 一个进程在加锁后的临界区中访问临界资源的时候,也可能发生切换 ,但临界区的资源其他进程访问不到,因为这个线程并没有解锁,所以其他线程还在申请锁那里阻塞着呢。

我们也可以不阻塞的申请锁,成功返回0,失败返回错误码。

cpp 复制代码
int pthread_mutex_trylock(pthread_mutex_t *mutex);

加全局锁后的抢票代码:

cpp 复制代码
int ticket = 10000;//总票数
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//定义chushihua全局锁,

void GetTicket(std::string name)
{
    while(true)
    {
        pthread_mutex_lock(&mutex);
        if(ticket>0)
        {
            usleep(1000);//充当抢票时间
            printf("get a ticket: %d\n",ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
        //pthread_mutex_lock(&mutex);//不能在这里加锁,因为可能break,导致无法解锁
        //实际情况还有后续动作,
    }
}

加局部锁,这里向封装好的线程库中传入的是锁的地址,然后再回调 GetTicket():

cpp 复制代码
int ticket = 10000;

//lock本身也是临界资源,全局锁
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//定义chushihua全局锁,

void GetTicket(pthread_mutex_t* mutex)//这里是地址
{
    while(true)
    {
        pthread_mutex_lock(mutex);
        if(ticket>0)
        {
            usleep(1000);//充当抢票时间
            printf("get a ticket: %d\n",ticket);
            ticket--;
            pthread_mutex_unlock(mutex);
        }
        else
        {
            pthread_mutex_unlock(mutex);
            break;
        }
    }
}
   
int main()
{
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex,nullptr);//局部锁

    std::string name1 = GetThreadName();//多执行流重入
    //这里传入局部锁,线程库参数T是pthread_mutex_t*
    Thread<pthread_mutex_t*> t1(GetTicket,name1,&mutex);//传参也可以传入结构体

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

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

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

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

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();
    pthread_mutex_destroy(&mutex);//销毁锁
}

注意:类内构造函数不能浅拷贝锁,即简单的 _data(data)。

  • pthread_mutex_t 是一个结构体 ,默认拷贝是浅拷贝 (复制内存内容),但互斥锁的语义要求所有线程操作同一个锁对象,浅拷贝会出现每个线程都有一份。

  • 每个线程的 _data 都独立拷贝了一份 mutex,因此每个线程持有不同的锁副本,GetTicket 中锁住的其实是各自副本,完全失去互斥效果,多个线程会同时进入临界区。

销毁互斥量(锁)需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量(锁)不需要销毁。
  • 不要销毁一个已经加锁的互斥量(锁)。
  • 已经销毁的互斥量(锁),要确保后面不会有线程再尝试加锁。
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

LockGuard.hpp:

这里是需要有一个已经存在的锁的指针来初始化,不能直接用锁本身,因为浅拷贝会拷贝出多个,比如定义的全局锁。

cpp 复制代码
#pragma once
//对锁做封装
#include <pthread.h>
class Mutex
{
public:
    Mutex(pthread_mutex_t* lock):_lock(lock)
    {}
    void Lock()
    {
        pthread_mutex_lock(_lock);
    }
    void Unlock()
    {
        pthread_mutex_unlock(_lock);
    }
    ~Mutex()
    {}
private:
    pthread_mutex_t* _lock;
};

class LockGuard
{
public:
    LockGuard(pthread_mutex_t* lock):_mutex(lock)
    {
        _mutex.Lock();
    }
    ~LockGuard()
    {
        _mutex.Unlock();
    }
private:
    Mutex _mutex;
};

使用 LockGuard 构造函数创建出一个临时的锁的指针对象,不是锁简单的浅拷贝,当结束完临界区访问会自动释放锁,符合RALL,与智能指针相识。为了将临界区代码区分出来,方便解锁,我们可以将临界区代码用裸的 { } 括出来,代表代码块,退出也会调用析构函数。

cpp 复制代码
void GetTicket(std::string name)
{
    while(true)
    {
        //非临界区代码
      
        {//直接括起来
            //pthread_mutex_lock(&mutex);
            LockGuard lockguard(&mutex); //
            if (ticket > 0)
            {
                usleep(1000); // 充当抢票时间
                printf("get a ticket: %d\n", ticket);
                ticket--;
                // pthread_mutex_unlock(&mutex);
            }
            else
            {
                // pthread_mutex_unlock(&mutex);
                break;
            }//也会调用析构函数
        }
        
        //非临界区代码
    }
}

传入结构体的方式使用线程库:

cpp 复制代码
class ThreadData
{
public:
    ThreadData(std::string name,pthread_mutex_t* pmutex)
        :_name(name),_pmutex(pmutex)
    {}
//private:
    std::string _name;
    pthread_mutex_t* _pmutex;
};

int ticket = 10000;

void GetTicket(ThreadData* data)
{
    while(true)
    {
        {
            LockGuard lockguard(data->_pmutex); //
            if (ticket > 0)
            {
                usleep(1000); // 充当抢票时间
                printf("%s get a ticket: %d\n", data->_name.c_str(), ticket);
                ticket--;            }
            else
            {
                break;
            }
        }
    }
}

int main()
{
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex,nullptr);//局部锁
    std::string name1 = GetThreadName();
    std::string name2 = GetThreadName();
    std::string name3 = GetThreadName();
    std::string name4 = GetThreadName();
    ThreadData data1(name1,&mutex);
    ThreadData data2(name2,&mutex);
    ThreadData data3(name3,&mutex);
    ThreadData data4(name4,&mutex);

    Thread<ThreadData*> t1(GetTicket,name1,&data1);//传参可以传入结构体
    Thread<ThreadData*> t2(GetTicket,name2,&data2);
    Thread<ThreadData*> t3(GetTicket,name3,&data3);  
    Thread<ThreadData*> t4(GetTicket,name4,&data4);
    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();
    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();
    pthread_mutex_destroy(&mutex);//销毁锁
 
}

个别系统,抢票代码会出现很多的票被同一线程抢完了,有线程长时间无法拥有资源,称为饥饿问题,要解饥饿问题,要让线程执行的时候,具备一定的顺序性,就是同步问题。

5.线程加锁的本质

经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题,为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下

锁 mutex 也是定义在内存中的 ,线程共享,1表示申请到锁,0表示锁被其他人占用。第一句将自己寄存器(上下文),改为0,第二句xchgb作用:将一个共享的mutex资源,从内存交换到自己的上下文中,属于线程自己(私有的),这些操作是原子的,不惧怕任何切换,然后再根据寄存器内容判断是否申请到锁,在这些操作中间 发生切换都不会影响锁的行为。

解锁就是将锁还给内存,将线程自己的上下文寄存器中的1 movb 到内存,然后唤醒其他等待锁的线程。关于加锁的原则:谁加锁,谁解锁。

6.可重入VS线程安全

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
  • 重入:是描述函数的特点,同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数,是线程不安全的情况之一

常见的线程不安全的情况:

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数。例如内部有一个static变量,也就是全局的,每调用一次都发生改变
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见不可重入(线程不安全的情况之一)的情况:

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

常见的线程安全的情况:

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

常见可重入情况:

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

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

本篇结束!

相关推荐
云泽8082 小时前
飞算 JavaAI 实战:如何自动化生成全流程追溯系统
运维·自动化·vibe coding·飞算javaai·飞算java ai炫技赛
运维大师2 小时前
【Linux运维极简教程】07-磁盘管理与LVM
linux·运维·服务器
乖巧的妹子2 小时前
刷题总结知识
linux·服务器·windows
拳里剑气3 小时前
Linux:基础IO
linux·运维·服务器·io
小小龙学IT3 小时前
Day 2-3:Linux 文件与目录操作
linux
小保CPP3 小时前
OCR C++ Tesseract基础用法
c++·人工智能·ocr·模式识别·光学字符识别
ruxshui3 小时前
数据运维生产故障案例笔记
大数据·运维
TCW11213 小时前
AI底层系列:用C++实现线性代数的公式推导与算法设计-8.线性变化(3)
c++·人工智能·算法
三8443 小时前
基本Linux命令总结
linux·运维·服务器