【linux系统】线程(线程TCB+线程同步和互斥+线程安全)

Linux学习06——线程控制与同步互斥 线程控制与同步互斥 阅读详情


一、线程概念

线程(Thread)是进程内的一个执行分支,线程的执行粒度,要比进程更细。

如何理解?
在这里插入图片描述
不同的操作系统对线程的定义都是一样的,但对线程的实现方式不一定相同。
有些操作系统会为线程专门定义TCB,比如windows, 但linux并没有专为线程定义TCB,而是使用PCB来模拟TCB。就像上图画的一样。

1.1 Linux下的线程

前文我们提到linux使用PCB来模拟TCB,即用进程的结构体来模拟线程。因此,很多教材会说:linux没有真正意义的线程!
为什么linux使用PCB来模拟TCB?
设计一个PCB就已经很麻烦了,在加上一个TCB,会更加麻烦。linux的开发人员发现,线程和进程有很多相似的地方,因此,linux的开发人员选择使用PCB来模拟TCB,这样不仅减少了代码量,也减少了维护的成本。

对于线程,我们还要记住一些概念:

进程是资源分配的基本单位
线程是操作系统调度的最小单位
执行流是程序在运行时的执行路径或执行顺序。
对于CPU来说,没有线程,进程的概念,它只知道执行流。
在Linux中:线程 <= 执行流 <= 进程

linux下的线程实际是对轻量级进程进行了封装。(封装在pthread库,后文会提到)

linux的线程:用户级线程
linux的轻量级进程: 内核级线程
二者的关系是1:1,一个轻量级进程封装为一个线程

在这里插入图片描述

相关知识:线程比进程更轻量化,体现在两方面:

  1. 创建和释放更加轻量化
  2. 切换更加轻量化。 在多线程环境中,线程之间的切换比进程之间的切换开销更小。这是因为线程共享了相同的地址空间,切换时无需切换内存上下文。

1.2 线程的优缺点

优点:

  • 轻量级:相比于进程,线程的创建和切换开销更小,因为它们共享相同的地址空间。
  • 资源共享:线程可以直接共享相同进程的内存空间和其他资源,定义一个全局变量,所有线程都能使用,这使得数据交换更为高效。
  • 并发性:线程的并发性使得程序可以同时执行多个任务,从而提高了系统的效率和响应性。
  • 协作性:线程之间可以方便地进行协作,共同完成某项任务,而无需额外的通信开销。
    多核利用:在多核系统中,线程可以被分配到不同的核心上并发执行,充分利用了硬件资源。

缺点:

  • 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
  • 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多

二、Linux下使用线程

前文提到linux没有真正意义的线程,因此linux没有线程的系统调用,但用户需要线程。因此linux在用户层定义了一个库:pthread(该库属于linux下的原生线程库)
在这里插入图片描述
pthread库包含了线程相关的函数,以及线程的TCB。(注意这里的线程的TCB,后文会重点介绍)

2.1 线程接口

创建线程:
在这里插入图片描述
参数1:pthread_t *thread

typedef unsigned long int pthread_t;

它是一个输出型参数,存储线程的tid(线程的标识符)

参数2:const pthread_attr_t *attr
用于设置新线程的属性。如果传入 NULL,则使用默认属性。

参数3:void *(*start_routine)(void*)
它是一个函数指针,指向的是一个返回值为void*,参数是void*的函数。这个函数将作为该线程的入口函数。相当于该线程的main函数。

参数4:void*arg
它将作为线程的入口函数的参数(参数3指向的函数的参数)。

返回值:0成功,失败返回错误码
用例:
main.cc

#include <pthread.h>
#include <unistd.h>
#include <iostream>
using namespace std;
void *thread_routine(void* arg)
{
    int val = *((int*)arg);
    cout << "Thread function received argument: " << val << endl;
    while(true)
    {
        sleep(1);
    }
    return NULL;
}
int main()
{
    pthread_t tid;
    int val = 10;
    pthread_create(&tid, nullptr, thread_routine, (void*)&val);
    sleep(1);
    cout << "thread's tid is "  << tid << endl;
    while(true)
    {
        sleep(1);
    }
    return 0;
}

我们使用g++进行编译,你可能会发生如下错误:
在这里插入图片描述
这是典型的链接错误,原因:pthread并不是c/c++库,如果要使用,你需要告诉g++编译器。(这一部分知识,属于linux下的动静态库的使用。)
【linux】动静态库

正确的方法:
在这里插入图片描述

2.1.2 Linux下查看线程

ps -aL
-a选项:显示所有用户的进程,而不仅仅是当前用户的进程。
-L选项:显示进程的线程信息

LWP,即Light Weight Process,轻量级进程,前文提到,linux只有轻量级进程的概念,这里轻量级进程即我们认为的线程。LWP下面的信息,即轻量级进程(线程)在linux内核的标识符。
注意:在linux内核里的标识符 != 线程的tid
上图左侧打印了线程的tid,140270593054464
这个数字怎么这么长?如果我们使用%p来打印:0x7f8739de3700
这个数字的十六进制刚好是一个64位机器的地址编号。要想知道为什么?后文的线程TCB会讲解。


线程等待:在进程中,有进程等待,即父进程要接收子进程的退出信息,同理,在线程中,主线程也可以通过线程等待,接受其他的线程的返回信息。

在这里插入图片描述
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数
thread:线程ID
retval:它指向一个指针,后者指向线程的返回值,如果不关心,传NULL
返回值:成功返回0;失败返回错误码

在这里插入图片描述
使用时:

void *ret;
pthread_join(tid, &ret);

需要注意,return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。


如果不想等待线程,可以使用线程分离。
在这里插入图片描述

  1. 线程分离后,主线程不会管分离的线程,分离的线程结束后自动销毁。
  2. 如果线程分离后,依然使用pthread_join等待则会报错。
  3. 线程分离可以是主线程进行分离,也可以是新线程自己调用函数分离。

除此之外:还有线程终止,线程取消……

pthread_exit函数
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_cancel函数
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码

在这里插入图片描述


2.2 线程的TCB

linux没有真正意义的线程,自然在内核里没有所谓的TCB。linux下的TCB是在pthread库中维护。
当我们使用pthread_create创建一个线程时,pthread库中便会创建一个TCB,并进行维护。
在这里插入图片描述
而上面我们打印的tid,实际上是线程的TCB的起始地址。

在TCB中,有2个属性不容易理解:线程栈和线程的局部存储

2.2.1 线程栈。

每一个线程都有自己的调用链,注定了每个线程都要有调用链对应的栈帧结构,用来存储线程函数中的局部变量、函数参数以及函数调用的返回地址等信息。这一点类似进程的main函数的函数栈帧。这里的线程栈由pthread来维护,有了线程栈,才能在内核里创建执行流,有了新的执行流,才是一个真正的线程。

小知识:为什么有了线程栈,内核才会创建执行流呢?内核明明没有线程的概念,又怎么会理解线程栈?
答:linux下只有轻量级进程的概念,自然linux会提供轻量级进程的接口,pthread库正是对轻量级进程的接口进行了封装,才在用户层创建了线程的概念。
linux下创建轻量级进程的系统接口是clone在这里插入图片描述
注意看前两个参数,fn是入口函数,child_stack是轻量级进程栈。
内核不认识线程栈,但它认识轻量级进程栈。而pthread_create的底层便是clone,这就是为什么内核能够给线程栈创建执行流。

如何验证?

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <vector>
using namespace std;
int g_val = 0;
void *thread_routine(void* arg)
{
    int* pi = (int*)arg;
    int i = *pi;
    int j = 0;
    while(j < 5)
    {
        cout << "thread-" << i << ", j=" << j << ", &j=" << &j << ", g_val=" << g_val << ", &g_val=" << &g_val << endl;
        sleep(2);
        j++, g_val++;
    }
    return NULL;
}
int main()
{
    int i = 0;
    std:;vector<pthread_t> tids;
    while(i < 4)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, thread_routine, (void*)&i);
        sleep(1);
        tids.push_back(tid);
        i++;
    }
    for(int j = 0; j < 4; j++)
    {
        pthread_join(tids[j], nullptr);
        sleep(1);
    }
    sleep(3);
    return 0;
}

在这里插入图片描述

不同的线程,都执行的同一个函数thread_routine,但函数内部的临时变量j,不是共享的,如果是共享的,那么不同的线程的j的地址应该一样。但结果表明,j的地址都不同。这也表明线程有自己独立的栈结构。

对于全局变量g_val,不同线程是共享的。

2.2.2 线程的局部存储

如果我想线程拥有私有的全局变量呢?这就要使用线程的局部存储。
int g_val = 0 改为 __thread in g_val = 0
此时不同的线程的变量g_val,都是不同的
在这里插入图片描述
在变量前加上 __thread, 就可以将该变量改为局部存储。除了这个关键字之外,pthread库中也有一些函数可以实现局部存储。

__thread 是 GCC 中用于实现线程局部存储(Thread Local Storage,TLS)的关键字。它可以用来声明线程局部变量,这些变量对于每个线程是唯一的,不同线程之间的变量不会相互影响。但是它只能声明内置类型,无法声明自定义类型。
需要注意的是,__thread 关键字是 GCC 的扩展语法,虽然在大多数情况下可以正常工作,但并不是 C 标准的一部分,因此在一些不支持 GCC 扩展语法的编译器中可能无法使用。在 C11 标准中引入了 _Thread_local 关键字,用于实现线程局部存储,具有类似的功能,而且是标准化的语法。


三、线程互斥

3.1 互斥的相关概念

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

3.2 非互斥的问题

非互斥,就表示多个线程可以同时访问一个资源。这会带来什么问题呢?
下面是一个抢票的代码:4个线程同时访问全局变量tickets

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <vector>
using namespace std;

int tickets = 1000;

void *getTickets(void* arg)
{
    int* pi = (int*)arg;
    int i = *pi;
    while(1)
    {
    //票大于0才抢
        if(tickets > 0)
        {
            usleep(1000);
            printf("thread-%d, get a ticket: %d\n", i, tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    int i = 0;
    std::vector<pthread_t> tids;
    while(i < 4)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, getTickets, (void*)&i);
        tids.push_back(tid);
        i++;
    }
    for(int j = 0; j < 4; j++)
    {
        pthread_join(tids[j], nullptr);
    }
    sleep(3);
    return 0;
}

结果:出现了负数
在这里插入图片描述
按照抢票的逻辑,票>0才抢,<=0就退出,怎么会出现负数呢?一张票应该属于一个线程,怎么出现了相同数字?问题就出在多线程并发访问。
像这类问题,归为多线程并发访问造成的数据不一致问题。

上面出现负数的原因:类似于下图
在这里插入图片描述
出现相同数字的原因:

那如何解决这种问题呢?通过加锁来解决。Linux上提供的这把锁叫互斥量。

3.3 互斥量(互斥锁)

互斥锁是如何实现线程互斥?

在这里插入图片描述
一个线程进入临界区之前,需要申请锁,只有拿了锁,才能执行临界区的代码。锁只有一把,故当一个线程拿了锁后,其他线程在申请锁时就会被阻塞,只有当持有锁的线程执行完临界区代码、进行解锁后,其他线程才可以申请锁。此时便实现了多个线程互斥的访问共享资源。

3.3.1 互斥量的接口

初始化互斥量
初始化互斥量有两种方法:
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL

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

互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,
那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

锁的接口都很简单,但使用时要注意很多细节。

3.3.2 加锁的注意事项

  • 加锁首先要保证正确性

以前文抢票的代码为例。我们定义一个静态锁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

正确的加锁方式如下:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <vector>
using namespace std;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int tickets = 1000;

void *getTickets(void* arg)
{
    int* pi = (int*)arg;
    int i = *pi;
    while(1)
    {    
        pthread_mutex_lock(&mutex);
        if(tickets > 0)
        {
            usleep(1000);
            printf("thread-%d, get a ticket: %d\n", i, tickets);
            tickets--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }  
    }
    return nullptr;
}
int main()
{
    int i = 0;
    std::vector<pthread_t> tids;
    while(i < 4)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, getTickets, (void*)&i);
        tids.push_back(tid);
        i++;
    }
    for(int j = 0; j < 4; j++)
    {
        pthread_join(tids[j], nullptr);
    }
    sleep(3);
    return 0;
}

错误的加锁方式:
在这里插入图片描述

  • 临界区的代码尽量要少 (锁的粒度应该尽可能小)

加锁的本质是用时间换安全。
实际表现:线程串行的执行临界区的代码。
而线程串行执行实际上降低了效率,因此临界区的代码越少,越能减少效率的降低。

  • 锁的分配要合理

上面正确加锁的代码实际还有一些问题。我的运行结果发现:1000票,大部分票被线程2和线程3抢了,线程4仅抢了97张票。这表明了一点:不同线程对锁的竞争能力是不同,这可能导致大部分时间,锁都在某一个线程上,这将导致线程的饥饿问题。因此,我们要合理的安排锁的分配,而这涉及到同步问题。

3.3.3 互斥锁的原理

锁可以保护临界区被线程互斥访问。但锁同时也是所有线程共享,属于共享资源,那锁自身的互斥问题呢?那必须将申请锁设计成原子的。(不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成)
首先我们要有一个共识:一条汇编语句是原子的。
但加锁的过程不可能仅仅只是一条汇编语句。那是如何实现加锁过程是原子的呢?

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单
元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一
个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪
代码改一下
在这里插入图片描述
动态演示:
请添加图片描述
通过lock,可以实现一个时间段内只有一个线程持有锁(上图的锁用1表示)

四、线程同步

前文提到加锁需要合理的分配锁,而如何合理的分配锁,就要进行线程同步。

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

先说一下,未同步的情况:
比如前文的申请锁的问题,每个新来的线程申请锁,不是排队竞争,而是一窝蜂的竞争。这就可能导致某个线程竞争能力很强,导致锁一直在它身上(比如它刚释放锁,又瞬间申请到锁)
在这里插入图片描述
如何解决呢?
解决方式有很多:条件变量,信号量……

4.1 条件变量

条件变量(Condition Variable)是一种线程同步机制,需要与互斥锁(Mutex)结合使用,用于在线程间传递某个条件的状态并实现线程的等待和唤醒。条件变量允许一个或多个线程在满足特定条件之前进入等待状态,并在条件被满足时被唤醒。
在这里插入图片描述

4.1.1 条件变量的接口

和互斥锁的接口函数一样简单:

初始化

  1. 静态:
    pthread_cond_t cond = PTHREAD_COND_INITIALIZER
    2.动态:
    int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
    参数:
    cond:要初始化的条件变量
    attr:NULL

销毁
int pthread_cond_destroy(pthread_cond_t *cond)

等待条件满足(休眠线程)
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释

唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond); 一次唤醒全部线程
int pthread_cond_signal(pthread_cond_t *cond); 一次唤醒一个线程

用例:

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

int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* Count(void* args)
{
    pthread_detach(pthread_self());
    //pthread_detach -- 线程分离, pthread_self -- 获取线程tid
    uint64_t number = (uint64_t) args;
    while(1)
    {
        pthread_mutex_lock(&mutex);//细节
        pthread_cond_wait(&cond, &mutex);//细节
        std::cout << "thread-" << number << " , cnt: " << cnt++ << std::endl;
        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
    for(int i = 0; i < 5; i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, Count, (void*)i);
    }
    sleep(3);
    while(1)
    {
        sleep(1);
        std::cout << "signal a thread" << std::endl;
        pthread_cond_signal(&cond);
    }
    return 0;
}

执行结果:很有序。没有出现一个线程连续持有锁。
在这里插入图片描述

上面的代码看起来很简单,但有一个细节不能忽视:即加锁和等待条件变量二者的顺序。
在这里插入图片描述
这里我们先谈谈为什么要让线程去休眠?一定是临界资源未就绪,如何知道临界资源未就绪,先要查看临界资源,查看临界资源的状态本身就是访问临界资源,访问临界资源必须要加锁。
因此顺序:加锁 – 查看临界资源的状态 – 进行休眠(pthread_cond_wait)
这就是为什么pthread_cond_wait需要在pthread_mutex_lock和pthread_mute_unlock之间的原因之一。

问题1:对于休眠线程有个疑惑,一个线程加锁后,才会考虑是否进行休眠,假如需要休眠,那不就导致这个线程是带锁休眠的吗? 拿了锁却跑去睡觉,这能允许?当然不可以。
pthread_cond_wait函数其实除了休眠线程,还会自动释放锁。
过程如下:
在这里插入图片描述

4.2 POSIX信号量

POSIX信号量和SystemV信号量(进程间通信)作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。

信号量是什么?信号量本质是个计数器。类似但是绝不等于 int cnt;(这里的绝不等于体现在信号量的++和–是原子的,但整形变量的++和–不是原子的, 具体是如何实现的,不知道)

那这个计数器记忆什么数据呢?临界资源的数量
在这里插入图片描述
当申请时,信号量会预定临界资源给你。就像电影院买票,只要你买了票,不管你是否去看,电影院一定有你的位置。信号量同理,只要你向信号量申请了临界资源,就一定有可以使用的临界资源(不用担心临界资源是否就绪、是否存在等问题)

正是这一特性,我们使用信号量就不用像互斥锁一样,加了锁还要用条件变量。

信号量有2个操作P和V,P表示申请,V表示释放。
使用时的顺序:

P(信号量)

使用临界资源

V(信号量)

4.2.1 POSIX信号量的接口

初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值

销毁信号量
int sem_destroy(sem_t *sem);

等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()

用例:

#include <iostream>
#include <queue>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
using namespace std;
#define CAPACITY 10 //总容量
queue<int> q;//临界资源
sem_t pushspace, popspace;
//pushspace : 剩余空间数目,初始为总容量
//popspace : 可pop空间的数目,初始为0
//改个名字
void P(sem_t& sem)
{
    sem_wait(&sem);
}
void V(sem_t& sem)
{
    sem_post(&sem);
}

void* push(void* args)
{
    pthread_detach(pthread_self());
    while(1)
    {
        P(pushspace);
        int data = rand()%100;
        cout << "push a data: " << data << endl;
        q.push(data);
        sleep(1);
        V(popspace);
    }
}
void* pop(void* args)
{
    pthread_detach(pthread_self());
    while(1)
    {
        P(popspace);
        cout << "pop a data: " << q.front() << endl;
        q.pop();
        sleep(2);
        V(pushspace);
    }
}

int main()
{
    srand(time(nullptr));
    sem_init(&pushspace, 0, CAPACITY);
    sem_init(&popspace, 0, 0);
    pthread_t tpush, tpop;
    pthread_create(&tpush, nullptr, push, nullptr);
    pthread_create(&tpop, nullptr, pop, nullptr);
    while(1)
    {
        sleep(1);
    }
    return 0;
}

在这里插入图片描述
这个例子并不能体现信号量的优势,这里只是简单展示了信号量的使用。
信号量的一个经典的应用场景:基于环形队列的生产者消费者模型

五、线程安全

可重入VS线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

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

常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况

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

常见不可重入的情况

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

常见可重入的情况

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

可重入与线程安全联系

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

可重入与线程安全区别

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

5.1 锁的相关概念和常见的锁

死锁

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

举个例子:小红、小明各持有5毛,一个棒棒糖需要1元。现在小红申请小明的5毛,以便筹齐1元购买棒棒糖,但小明也想买棒棒糖,也申请小红的5毛。现在,小红和小明都想要对方的5毛,又不肯放弃自己的5毛,那不就卡住了吗。死锁就类似于小红小明的情况。

死锁有四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

只要是死锁,一定满足这4个条件。

那如何解决死锁?

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

避免死锁算法

  • 死锁检测算法
  • 银行家算法

悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行
锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,
会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不
等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁,公平锁,非公平锁?

5.2 STL、智能指针与线程安全

STL中的容器是否是线程安全的?
不是.原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.

智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数

5.3 线程安全的单例模式

概念
设计模式是一种在软件设计中反复出现的通用解决方案,它是由经验丰富的软件开发人员总结出来的。设计模式提供了一种解决特定问题的方法,可以帮助开发人员更加高效、灵活地设计和实现软件系统。

设计模式的类型

  • 创建型模式:用于处理对象的创建机制
  • 结构型模式:用于处理类或对象的组合
  • 行为型模式:用于处理对象之间的通信和职责分配。

单例设计模式是一种创建型模式,它确保一个类只有一个实例,并提供一个全局访问点来访问该实例。单例模式通常用于管理全局资源,例如日志系统、数据库连接池等。

单例模式的关键点是:

私有构造函数:单例类的构造函数必须是私有的,这样外部代码就无法直接实例化该类。
静态成员变量:单例类必须拥有一个静态成员变量来存储唯一实例。
静态方法:单例类通常提供一个静态方法来获取该唯一实例,这个方法会负责创建实例(如果实例不存在的话)并返回它。

单例模式的实现方式常见的有2种:

  • 饿汉实现方式:吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.
  • 懒汉实现方式:吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.懒汉方式最核心的思想是 “延时加载”. 从而能够优化服务器的启动速度.

饿汉方式实现单例模式

template <typename T>
class Singleton {
	static T data;
public:
	static T* GetInstance() 
	{
		return &data;
	}
};

只要通过 Singleton 这个包装类来使用 T 对象, 则一个进程中只有一个 T 对象的实例.

懒汉方式实现单例模式(线程不安全)

template <typename T>
class Singleton {
	static T* inst;
public:
	static T* GetInstance() 
	{
		if (inst == NULL) 
		{
			inst = new T();
		}
		return inst;
	}
};

需要用时,再创建。

那这与线程安全有什么关系呢?

懒汉模式,第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例.但是后续再次调用, 就没有问题了.
懒汉方式实现单例模式(线程安全版本) – 双重检查锁懒汉式

template <typename T>
class Singleton {
	volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
	static std::mutex lock;
public:
	static T* GetInstance() {
	if (inst == NULL) 
	{ // 双重判定空指针, 降低锁冲突的概率, 提高性能.
		lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
		if (inst == NULL) 
		{
			inst = new T();
		}
		lock.unlock();
	}
	return inst;
	}
};

注意事项:

  1. 加锁解锁的位置
  2. 双重 if 判定, 避免不必要的锁竞争
  3. volatile关键字防止过度优化
STM32----SPI+DMA高效读写外部Flash的配置与实战 本文详细介绍了STM32通过SPI+DMA高效读写外部Flash的配置与实战方法。针对大量数据传输场景,使用DMA技术可显著提升系统效率,释放CPU资源。文章包含完整的代码实现、常见问题调试技巧以及性能优化建议,帮助开发者快速掌握这一关键技术。 阅读详情

相关推荐

机器学习之性能度量

废话不说,本文分为两个部分: 第一部分,我对周志华教授《机器学习》这本书中性能度量知识点中疑难点的学习感悟;第二部分,我感觉该书2.3节 性能度量 中最后部分的代价曲线表述有误,苦于没找到联系教授的正确方式,故希望与广大网友一起探讨。

hustqb的博客 4628

【Linux学习】多线程——线程控制 | 线程TCB

介绍了线程创建,线程结束,线程等待,线程分离等线程控制的内容,还有C++对线程库的简单封装,以及线程库中的TCB结构和线程tid的本质。

weixin_63726869的博客 2184

Zynq UltraScale+网络加速方案:用AXI Ethernet Subsystem实现2.5G吞吐量的配置秘籍

本文详细介绍了在Xilinx Zynq UltraScale+平台上使用AXI Ethernet Subsystem实现2.5Gbps网络吞吐量的配置方法。通过硬件设计优化、Vivado工程配置、Linux驱动调优及性能测试等关键步骤,为工业视觉和5G前传等高速网络应用提供可靠解决方案。

weixin_30485379的博客 614

线程TCB详解

TCB简介 操作系统中一个线程对应着一个TCB(Thread Control Block),叫做线程控制模块,控制着线程的运行和调度。 TCB组成 1、threadID:线程的唯一标识。 2、status:线程的运行状态 3、register:线程关于CPU中寄存器的情况 4、PC程序计数器:线程执行的下一条指令的地址 5、优先级:线程在操作系统调度的时候的优先级 6、线程的专属存储区:线程单独的存储区域 7、方法栈:线程执行的方法栈,用来保存线程当前执行的方法的信息 ...

qq_40276626的博客 1万+

人大金仓V8R3企业版License更新全攻略:从查看到重启一步到位

本文详细解析了人大金仓V8R3企业版License更新的完整运维流程,涵盖从诊断当前授权状态、获取与验证新文件、精准定位旧文件路径,到安全替换文件及服务重启验证的全过程。重点针对更新流程中常见的版本匹配、文件定位、权限与服务重启等关键环节提供了实战解决方案和避坑指南,帮助运维人员高效、平稳地完成授权续期。

redis7keeper的博客 896

从零搭建宝兰德BES单机版:分离式安装、环境变量配置到控制台访问的避坑指南

本文详细介绍了从零搭建宝兰德BES单机版的完整流程,包括分离式安装、环境变量配置和控制台访问的避坑指南。针对中间件安装部署中的常见问题,提供了账号规划、JDK配置、实例创建和防火墙设置等实用解决方案,帮助开发者和运维人员高效完成BES中间件的部署工作。

weixin_32576389的博客 372

Linux多线程

Linux多线程详解

weixin_58867976的博客 2984

【DMA】认识 DMA 及其工作流程(与传统CPU存取方式的区别)

【DMA】认识 DMA 及其工作流程(与传统CPU存取方式的区别)

challenglistic的博客 5万+

线程

线程(TCB)的概念 1. 线程是一个进程内部的控制序列 2. 一切进程至少都有一个执行线程 进程和线程: 1. 线程是在进程内部运行的执行流(线程在进程的地址空间内运行) 2. 线程执行粒度更细 3. 线程之间大部分资源是共享的(但也有私有的:线程拥有独立的上下文数据、有自己的私有栈结构) 进程的多个线程共享:地址空间、代码数据、文件描述符、当前工作目录、用户id和组id...

zhangjie1315的博客 499

OPENSSL使用方法

预备知识KEY 通常指私钥。CSR 是 Certificate Signing Request 的缩写,即证书签名请求,这不是证书,只是包含申请证书的基本信息。生成证书时要把这个提交给权威的证书颁发机构,颁发机构审核通过之后,再根据这些申请信息生成相应的证书。CRT 即 certificate的缩写,即证书。 X.509 是一种证书格式。对X.509证书来说,认证者总是CA或由CA指定的人,一份...

zxm8513优快云博客 1212

【AutoSAR】【以太网】TCP/IP 栈详述与配置

目录 一、TCP/IP栈架构 二、TCP\IP栈的可扩展性 三、以太网协议V4 四、以太网协议 V6 五、基于IP的协议 六、函数调用序列图 一、TCP/IP栈架构 根据以上架构图,该栈主要包括:UPD\TCP\DHCP\ICMP\IPV4\IPV6\ARP; 二、TCP\IP栈的可扩展性 TCP\IP栈为适应不同的应用场景需求,将分为三类: 配置工程里的配置项: 三、以太网协议V4 1 IPV4 Internet Protocol (IP)是T.

专注汽车软件开发、AutoSAR、车载以太网、SOA、EE架构。 5695

进程PCB与线程线程TCB

一、PCB(进程控制块) (1)PCB作用 ①作为独立运行基本单位的标志。系统是通过PCB感知进程存在的。事实上,PCB已成为进程存在于系统中的唯一标志。 ②能实现间断性运行方式。 ③提供进程管理所需要的信息。 ④提供进程调度所需要的信息。 ⑤实现与其它进程的同步与通信 (2)PCB中的信息 ①进程标识符 进程标识符用于唯一地标识一个进程。一个进程通常有两种标识符: (1)外部标识符。为了方便用户(进程)对进程的访问,须为每一个进程设置一个外部...

shintyan的博客 1万+

安全运营面试总结

答案可以在我的博客中查找 1.自我介绍 2.TCP/IP七层模式 3.端口号(常见协议 数据库) 4.Owasp top 10 5.渗透测试过程 6.漏扫工具 7.中间件漏洞 8.应急响应 9.项目 10.SQL注入类型 11.TCP 三次握手 四次挥手 12.你有什么要问我的 1.自我介绍 2.项目介绍 3.宽字节注入的原理 4.渗透流程 5....

qq_51577576的博客 4413

Linux线程本质与pthread实战:从clone系统调用到线程池

理解操作系统中的执行流时,进程与线程的边界常令人困惑。在Linux内核视角下,线程并非独立结构,而是通过clone系统调用创建的task_struct,只是共享地址空间等资源的轻量级进程。pthread库则封装了底层细节,为开发者提供线程控制、同步及私有存储管理能力。互斥锁与条件变量解决了协作执行时的竞争与等待问题,而线程安全、可重入函数及TLS机制保障了并发程序的稳定性。在生产环境中,频繁创建线程的开销不容忽视,线程池因此成为高并发任务的常见优化手段。本文从内核到用户态,系统拆解Linux线程的实现、同步

weixin_29246195的博客 108

霍尼韦尔SoftMaster使用说明

霍尼韦尔SoftMaster使用说明

温度PID控制PLC程序

温度PID稳定控制程序,适用于欧姆龙PLC,对其他PLC也具有很大参考价值

Linux线程(线程的概念、线程的调度比进程快的原因、Linux 与 Windows 实现线程的不同策略、Linux的线程、C++ 11线程库、线程安全问题、线程的互斥、线程的同步、生产消费模型)

线程的概念、线程的调度比进程快的原因、Linux 与 Windows 实现线程的不同策略、Linux的线程、C++ 11线程库、线程安全问题、线程的互斥、线程的同步、生产消费模型

laimaxgg 1701

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

Linux线程(Linux和Windows的线程区别、Linux的线程函数、互斥、同步)

**线程的概念**: **线程是 CPU 调度的基本单位**。它被包含在进程之中,**是进程中的实际运作单位**。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。 **线程和进程的区别**: 1. 线程是 CPU 调度的基本单位,而进程(内核角度)是承担分配系统资源的基本实体。 2. 线程和进程最大的区别是,线程之间共用一个地址空间,而进程的地址空间彼此独立。

laimaxgg 1378

蓝牙模块HM-10手册

蓝牙模块HM-10手册,该手册对蓝牙模块HM-10进行了详细的介绍和使用方法以及一些AT指令。

<Linux> 线程安全

线程互斥、可重入线程安全、死锁、同步(条件变量)

prodigy623的博客 1239

多线程之同步与互斥

多线程1.线程的基本概念2.线程的互斥和同步2.1 基本概念2.2 互斥的实现2.3 同步的实现补充:生产消费者模型参考资料 1.线程的基本概念   多线程可以将计算密集型(实时)和I/O密集型(实际中会等待)应用分配到多个处理器上执行,提高执行效率,但是会增加调度成本。   Linux没有专门的TCB(Thread Control Block),而是复用了进程的PCB;不同的PCB指向同一个地址空间,并将地址空间的数据段和代码段进行划分分配给不同的PCB。   进程是资源分配的基本单位,线程是运算调度的基

万法归一 2525

【Linux】线程

线程相关内容

weixin_74078718的博客 3397

Linux:多线程(二.理解pthread_t、线程互斥与同步)

关于静态变量与全局变量的小知识:静态变量(包括静态局部变量和静态全局变量)以及全局变量的初始化时间是在程序执行之前的一个特定阶段对于全局变量,静态变量,它们的生命周期与整个程序的生命周期相同。当程序结束时,操作系统会自动释放程序占用的资源作用域不同全局变量的作用域是整个程序,即在定义它的文件中的任何地方都可以访问。静态变量的作用域限定在定义它的函数或文件内部,外部无法直接访问。生命周期不同全局变量的生命周期是整个程序的运行期间,即在程序启动时分配内存,在程序结束时释放内存。

weixin_70067429的博客 1236

Linux之线程及线程安全详解

在操作系统中,进程是资源分配的基本单位,那么线程是什么呢?线程是调度的基本单位,我们该怎么理解呢?

m0_74316391的博客 1404

【Linux系统】线程:线程库 / 线程栈 / 线程库源码阅读学习

1、线程库的介绍 / 封装 / 功能 / 加载位置 / mmap区域;2、线程栈:独立开辟 / 系统调用clone / 局部存储__thread;3、线程库源码的阅读:pthread_create的源码 / struct pthread / ALLOCATE_STACK / create_pthread / do_clone / ARCH_CLONE;4、再谈线程栈:线程栈是独立分配的,固定的,不可增长的,但不是绝对隔离的

从不依靠,从不寻找,非常沉默,非常骄傲。 你必须非常努力,才能看起来毫不费力。 1311

深入理解 Linux 多线程

掌握线程:区分线程与进程,能创建/控制/终止/等待,理解分离与安全,熟练同步(互斥量、条件变量、POSIX 信号量、读写锁),并能解决读者—写者问题。

Edison's 小宇宙 5万+

Linux线程

该系统调用成功返回0,失败返回错误码,传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误,但pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做),而是将错误代码通过返回值返回,尽管pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码,对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。

m0_74494481的博客 976

【Linux】线程同步与互斥(四):线程池与任务管理

本文介绍了Linux线程池的设计与实现,重点分析了传统线程使用方式的弊端及线程池的优势。线程池通过预先创建固定数量的工作线程并重复使用,有效避免了频繁创建销毁线程的开销,同时能够控制并发数量,提高系统资源利用率。文章详细阐述了线程池的三大核心组件:任务队列、工作线程和管理接口,并采用生产者-消费者模型进行任务调度。在实现层面,提出了基于C++的面向对象设计方法,通过抽象Task基类支持多种具体任务类型。线程池作为多线程编程的重要实践,可显著提升系统性能和管理效率。

半截诗的博客~ 1000

【Linux系统编程】(四十六)线程池原理与实现:从固定线程池到线程安全单例模式

本文深入讲解了线程池的原理与实现。首先分析了线程池的必要性,指出线程创建销毁的高开销问题,提出线程复用+任务队列的核心思想。接着从零实现了一个固定线程数的线程池,包含线程管理、任务队列、同步机制等核心组件,详细解析了任务处理循环和线程安全设计。然后升级为线程安全的单例模式,采用双重检查锁定确保全局唯一实例。最后提供了测试代码验证功能,并给出动态扩容、任务优先级等进阶优化方向。通过理论讲解和完整代码实现,帮助读者彻底掌握线程池的设计与开发。

2301_79248256的博客 757
上一篇: 【linux系统】环境变量
下一篇: 【linux网络】socket编程(TCP/UDP)
ProcedureStone
博客等级 码龄4年 254粉丝 64原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值