Linux多线程

linux · 浏览次数 : 21

小编点评

多线程是指在一个进程中,允许多个线程同时执行的能力。线程是操作系统分配资源和调度的基本单位,可以提高程序的执行效率。 1. **线程与进程的区别** - **地址空间和页表的映射关系**:进程拥有独立的地址空间,而线程共享进程的地址空间。线程的地址空间相对较小,因此不需要为每个线程建立完整的页表。 - **线程与进程的属性**:线程与进程有很多相似之处,如PCB(进程控制块)结构。在Linux中,线程实际上是基于PCB的轻量级进程。 - **线程的创建、终止、等待、取消**:线程的创建和终止过程与进程类似,但线程的生命周期通常比进程短。线程可以通过join()函数等待其他线程结束,也可以通过detach()函数分离。 - **线程的属性**:线程拥有独立的上下文结构、栈结构,且线程之间共享进程的资源。 2. **线程的优点与缺点** - **优点**:线程与进程之间的切换相比,消耗的系统资源较少,因为线程拥有进程的大部分资源。 - **缺点**:线程的健壮性降低,因为一个线程的异常可能导致整个进程的终止。 3. **线程控制** - **创建、终止、等待、取消**:线程的创建函数是`pthread_create()`,终止函数是`pthread_exit()`,等待函数是`pthread_join()`,取消函数是`pthread_cancel()`。 - **线程分离**:可以通过`pthread_detach()`函数使线程脱离主线程的管控,但需要注意线程的创建顺序以避免错误。 4. **同步与互斥** - **同步与互斥**:通过互斥锁(如`pthread_mutex_t`)和条件变量(如`pthread_cond_t`)实现线程间的同步和互斥。 - **死锁**:死锁是由于多个线程互相持有对方所需的资源而导致的永久等待状态。 5. **线程库的原理** - **线程库**:线程库是对轻量级进程的封装,提供了线程的创建、销毁、同步、互斥等功能。 - **线程局部存储**:通过`__thread`关键字可以在每个线程中存储局部变量,使得每个线程都有自己的一份数据副本。 6. **生产者和消费者模型** - **生产者和消费者模型**:通过一个阻塞队列来解决生产者和消费者之间的强耦合关系。 - **信号量**:信号量用于控制对公共资源的访问,如环形队列的生产和消费。 7. **线程池** - **线程池**:线程池是一种线程使用模式,用于减少线程创建和销毁的开销,提高效率。 8. **线程安全的单例模式** - **单例模式**:确保一个类只有一个实例,常用于管理大量数据。 以上是多线程的一些基本概念和原理,涵盖了从线程的创建、管理到同步互斥等多个方面。

正文

多线程

1. 线程概念

1.1 地址空间和页表的映射关系

  • 地址空间是进程能看到的拥有的资源的大小
  • 页表决定了进程实际拥有资源的大小和位置

所以对进程地址空间和页表进行适当的资源划分,就可以对一个进程的所有资源进行分类

进程地址空间如何与页表和物理内存产生映射的过程

首先进程地址空间有4G,那么如果要建立一个个对应的页表的话需要建立2^32个条目,一个条目里都有权限,是否命中等属性,所以如果要建立2^32个的话每个进程都要花费近20G资源,几乎不现实,所以,页表的映射是以下过程。

  • 1. 首先对于进程地址空间,每个地址都是32位的,那么我们可以将32位划分位3部分,10位,10位,12位,首先对于前10位。

  • 2. 对于前10位来说,为每个前10为建立一个页目录,即为前10位的划分。

  • 3. 对于第二个10位,前边创建的页目录即存在这里,每个页目录有1024个,对应每个页表项(页表)。

  • 4. 对于后12位来说,当我们根据前边两个过程时,到了页表那里,页表里存的就是对应的每个内存块的起始物理,内存块的大小位4KB刚好是2^12个大小,所以后12位即为找到物理起始地址后的偏移量。

整体流程 : 对于前10位,每个位建立一个1024个页目录,每个页目录建立1024个,存储了每个前20位在物理内存中的起始地址,这个物理内存的起始地址加上虚拟地址的后12位即偏移量,便可真正找到实际物理内存的地址。

1.2 线程概念

进程是资源分配的最小单位,线程是CPU调度的最小单位

对于一个进程来说,一个进程创立的过程为:

创建对应的内核数据结构task_struct等----->根据进程地址空间创建页表-------->与物理内存建立映射

那么,如果只想执行进程的一部分呢,就引出了线程的概念,我们可以创建多个task_struct每个PCB执行代码的一部分,但是不需要创建新的系统资源,因为就一个页表和地址空间和内存,如图

那么以上绿色的即为线程,线程执行是进程的一个执行流,线程是cpu调度的基本单位。

线程也需要被cpu执行,根据先描述再组织,所以也需要特定的数据结构和对应的管理办法,让线程执行进程的一部分,但是要不要为进程创建一系列对应的体系结构呢?不需要,既然他和进程pcb属性这么像,干脆直接复用linux里的进程的一系列系统即可,进行细节改动,这样可以高内聚低耦合。

  • 那么如何看待之间的进程的概念呢?

之前的进程与也是通用的,只不过现在引入了线程,之前的进程写的代码只有一个执行流,只有一个执行流的这个叫做主执行流,进程依靠的是这个主执行流,所以现在进程是系统资源分配的基本实体,之前内部只有一个执行流,但是现在随着线程的引入,进程内部可以存在多个执行流

进程 :承担系统资源分配的基本单位,以下这些的集合统称进程。

  • 既然线程概念已经被引出,那么线程也要跟进程一样,要有独立的id,保存上下文的寄存器,被调度挂起等,这么说的话OS需要再为线程设计单独的一个结构和体系吗? 并不需要,因为线程跟进程的属性很像,所以直接套用之前的PCB的结构和体系即可,直接复用进程PCB,用PCB表示Linux中的线程。

  • 所以在Linux中,没有真正意义的线程,是用PCB来模拟线程的。

  • 站在CPU的视角,每个线程都是一个轻量级线程。

  • 进程用来申请整体资源,线程用来向进程要资源。

  • Linux无法直接提供创建线程的系统调用,只能提供轻量级进程,但是OS和用户只认线程,所以Linux用第三方软件层来进行桥接。

  • 轻量级进程lwp,cpu调度是以lwp来调度的,对于进程中的主线程,pid与lwp一样

1.3 示例代码

#include <pthread.h>

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

要使用pthread_create的时候,需要手动去动态链接pthread库,才能正常编译

#include<iostream>
#include<cassert>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void *func(void * args){
    while(1){
        cout<<"我是一个线程....\n";
        sleep(1);
    }
}
int main(){
    pthread_t tid;
    int n=pthread_create(&tid,nullptr,func,(void*)"线程1");
    assert(n==0);
    while(1){
        cout<<"我是主线程....\n";
        sleep(1);
    }
    return 0;
}

使用ps -aL查看系统所有线程

1.4 线程的属性

  • 线程一旦被创建,进程内的几乎所有的资源都被线程共享
  • ⭐线程什么是私有的?

(1) PCB属性私有

(2) 上下文结构私有 ,他是线程动态运行的证据,能体现出线程动态切换的属性

(3) 每一个线程都有独立的栈结构

线程的优点:

  1. 线程与进程之间的切换相比,消耗的系统资源比进程少

这是因为在cpu里有一个cache,这个东西会存储一个进程经常用的到的热点数据,即为经常访问的数据

他的速度比内存快比cpu慢所以cpu可以直接从这里拿,找不到再去内存中找

那么线程切换,由于线程拥有几乎所有的进程的资源,所以这个cache里的东西不需要切换

但是进程如果切换的话,那么就需要切换了,所以进程与线程之间消耗资源的差距主要体现在这一方面

  1. 能充分利用多处理器的可并行数量

  2. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

线程的缺点:

  1. 健壮性降低:

对于单进程多线程而言,假设有一个线程产生异常,那么通常会给进程发送对应的信号进而导致进程终止,其他线程也随之终止

2. 线程控制

不管是C还是C++在linux上编译时都要对原生线程库进行显式链接-lpthread

头文件:#include <pthread.h>

2.1 线程的创建、终止、等待、取消

线程的创建

函数:

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

参数:

pthread_t *thread: 输出型参数 ,输出参数线程的tid

const pthread_attr_t *attr:设置线程的优先级等属性,通常设为nullptr

void (start_routine) (void *): 回调函数,创建的这个线程的执行流的操作即为该回调函数的执行顺序

void *arg:传给线程启动函数的参数,可以是一个线程的名字等参数

eg:

pthread_t tid;
pthread_create(&tid, nullptr, func, (void*)"线程1");

线程的终止

线程的终止不能用exit()函数,因为该函数是进程的终止函数

线程有自己的终止办法:

  1. *调用函数void pthread_exit(void retval);
void pthread_exit(void *retval);

retval为线程退出信息的起始地址,该类型可以是任意类型

  1. 在线程的回调函数中直接return (void*类型)
// 1.多线程入口函数
void *start_routine(void *args)
{
    ......
    return (void*)100;
    //return (void*)一个new的结构体或者类型也可以,但注意要是右值
}

线程的等待

int pthread_join(pthread_t thread, void **retval);//成功返回0  失败返回-1

参数:

pthread_t thread :要等待进程的tid

void **retval : 输出型参数,二级指针,通常保存线程退出的信息等内容

对于第二个参数,因为正确进程的终止或者正常退出都会返回一个(void*)类型的东西,

这个指针可以是一个结构体的起始地址或者数组的起始地址或者一个类型的地址,

那么我们可以用一个参数放到这个函数作为输出型参数,然后等待完后我们就可以访问这个输出型参数的内容,只不过这个输出型参数是一个指针类型

eg:

//创建一个线程返回信息的结构体
class ThreadReturn{
public:
    int exit_code;
    int exit_result;
};

ThreadReturn* res=nullptr; 
int n=pthread_join(it->tid,(void**)&res);
cout<<"线程退出码: "<<res->exit_code;
cout<<"线程退出的结果: "<<res->exit_result;

线程的取消(不常用)

int pthread_cancel(pthread_t thread);

参数: pthread_t thread: 要取消的线程的pid

当该线程取消时,退出信息为-1,如果有线程等待的话,线程被取消,那么此时退出的信息就是-1

综合应用

eg:

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

#define NUM 10

//创建一个包含线程信息的结构体
class ThreadData
{
public:
    int number;
    pthread_t tid;
    char namebuffer[64];
};


//创建一个线程返回信息的结构体
class ThreadReturn{
public:
    int exit_code;
    int exit_result;
};


// 1.多线程入口函数
void *start_routine(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    int cnt=1;
    while (1)
    {
        cout << "I am new thread,name: " << td->namebuffer << endl;
        sleep(1);
        if(cnt++==5) break;
    }
    //这里要用堆上的,如果不是堆空间的话,那么多个线程会用同一个地址空间,可能会导致数据出错
    ThreadReturn* res=new ThreadReturn();
    res->exit_code=td->number;
    res->exit_result=1019;
    // delete td;
    //2.线程的退出
    return (void*)res;
}

int main()
{

    // 1.线程的创建
    vector<ThreadData *> threads;
    for (int i = 0; i < 10; i++)
    {
        // pthread_t tid;
        // char namebuffer[64];
        ThreadData *td = new ThreadData();
        td->number=i+1;
        snprintf(td->namebuffer, sizeof td->namebuffer, "%s:%d", "Thread", i + 1);
        pthread_create(&td->tid, nullptr, start_routine, td);
        threads.push_back(td);
        // sleep(1);//这里加上sleep就可以正确了,但是不加的话就全是10号
    }

    // 3.线程的等待,并获取返回值结果
    for (auto &it : threads)
    {
        ThreadReturn* res=nullptr;//获取线程退出信息
        int n=pthread_join(it->tid,(void**)&res);
        cout<<"wait success,exit_code:"<<res->exit_code<<" exit_result:"<<res->exit_result<<endl;
        delete res;
        delete it;
    }
    cout << "main thread quit" << endl;
    return 0;
}


2.2 线程分离

1. 默认情况下,新创建的线程是joinable的,必须被join的,线程退出后必须对其进行pthread_join()

操作,进而回收线程的资源,否则将造成系统泄漏

2. 假设主线程不需要关心线程的退出信息和返回结果,那么此时pthread_join对主线程来说是一种负担,此时可以让线程脱离主线程的管控,线程结束后告诉操作系统然后自行回收

线程分离的函数:

int pthread_detach(pthread_t thread);

​ 参数 : thread为当前线程的tid

​ 返回值 : 成功返回0,失败返回对应的错误码

但要线程分离首先得知道当前线程的tid才行需要引入函数pthead_self():该函数返回当前线程的tid

函数pthread_self():

pthread_t pthread_self(void);

​ 返回值 : 当前线程的tid,为十进制,可以手动转化为16进制

有了获取获取当前线程的函数,对于一个线程我们可以

pthread_detach(pthread_slef());

此时该线程将分离主线程,结束后告诉系统,回收资源

但是这样有坏处,假设后续主线程对他join了,由于创建线程后有多个执行流,所以如果要被分离的线程没有赶在主线程前边,那么主线程不知道,将会进行阻塞等待了

eg:

void *startroutine(void *args){
    pthread_detach(pthread_self());//分离主线程
    string name=static_cast<const char*>(args);
    while(true){
        cout<<"我是"<<name<<endl;
        sleep(1);
    }
}
int main(){
   	pthread_t tid; 
    pthread_create(&tid,nullptr,start_routine,(void*)"线程1");
   	//进行线程等待,但是线程自己分离了,预期效果应该是返回的是错误码
    int n=pthread_join(tid); 
    return 0;
}
    

以上代码

预期现象:

​ 创建线程后,线程内部进行分离,主线程将不再等待新线程

实际现象:

​ 创建线程后,线程内部进行分离,但是主线程依旧等待新线程

原因 : 因为创建新线程后,产生了一个新的执行流,两个线程随机独立运行,此时如果主线程先运行了,那么主线程将直接跑到了pthread_join等待了,然后新线程去分离了,但是主线程不知道,所以主线程继续等待了,此时就造成了错误

**改进办法 : **

主线程创建完新线程后,由主线程去分离新线程即可避免错误

int main(){
   	pthread_t tid; 
    //线程创建,创建新线程
    pthread_create(&tid,nullptr,start_routine,(void*)"线程1");
   	//线程分离,分离创建的新线程
    pthread_detach(tid);
    int n=pthread_join(tid); //那么此时将不会去等待新线程了
    return 0;
}

2.3 线程库的原理

在Linux中,只有轻量级进程这一概念,但是程序员只认线程这一概念,所以产生了线程库,对轻量级进行封装,线程库是用户级的

对于一个线程,存在自己的属性,上下文和属于自己的栈结构和tid等结构属性

实现过程:

  • 首先将线程库进行动态链接,线程库从磁盘加载到内存,然后进行动态链接,将库加载到共享区中
  • 对于一个新线程,线程库创建一个线程并且创建对应的属性等操作,位于共享区
  • 每个线程都有自己的私有结构,包括栈区等,这在共享区内
  • 所以对于每个线程的tid,线程的tid其实就是该线程在共享区的进程地址空间按的起始地址,拿到这个起始地址就可以访问线程的各种属性了

所以pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

线程局部存储

假设有一个全局属性,每个线程都可以访问他,每个线程都可以对他进行操作,但是只有一份

如果想让这个全局变量,对于每个线程都有一份,那么可以在全局变量的前边加上__thread关键字

__thread int val=100;

这样每个线程都有自己的一份val,存在新线程局部存储区域

3. 同步与互斥

线程互斥相关背景概念

  • 临界资源 : 多个线程执行流共享的资源,称为临界资源
  • 临界区 : 每个线程访问临界资源的代码片段,称为临界区
  • 互斥 : 任何时刻,互斥保证只能有一个线程执行流在临界区执行,访问临界资源,对临界资源起保护作用
  • 原子性 : 不会被任何调度机制打断的操作,这个操作只有两个状态,要么不做,要么做就一次性做完

线程同步 : 在保证数据安全的条件下,让线程按照某种特定的顺序访问临界资源,避免饥饿问题,从而造成线程之间的协同,这叫作同步

线程互斥是用来保护临界资源的操作,多个线程不能同时访问临界资源,容易出问题,让多个线程串行执行

多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换

例如以下的抢票程序,定义6个线程去抢票。票是临界资源

#include <iostream>
#include <string>
#include<vector>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 6   //创建线程的数量
//本节:抢票和死锁 ,代码hpp头文件来自lesson32
int tickets=10000;
//定义互斥量的办法1
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;


void* getTickets(void* args){

    string thread_name=static_cast<const char*>(args);
    while(true){
        pthread_mutex_lock(&mutex);//锁上
        if(tickets>0){
            cout<<thread_name<<" get ticket,tickets less: "<<tickets--<<endl;
            pthread_mutex_unlock(&mutex);//解锁
        }
        else{
            pthread_mutex_unlock(&mutex);//解锁
            break;
        }
        usleep(1000);
    }
}

int main()
{
    vector<pthread_t> tids;
    for(int i=0;i<NUM;i++){
        pthread_t tid;
        char namebuffer[64];
        snprintf(namebuffer,sizeof namebuffer,"[%s %d]","Thread",i+1);
        pthread_create(&tid,nullptr,getTickets,(void*)namebuffer);
        tids.push_back(tid);
        usleep(1024);   //这里要usleep一下,因为可能主线程太快导致namebuffer被覆盖
    }

    for(const auto& id:tids){
        pthread_join(id,nullptr);
    }

    return 0;
}

为什么要对以上代码进行互斥操作呢?

假设有一个线程正在抢票且票=1,那么他此时进来if(tickets>0)里了,但假设此时刚好时间片用完了,那么轮到别的线程了,因为上一个线程还没减去票,那么当前线程看到的票的数量还是>0那么此时我--,然后轮到刚刚那个线程了,那么那个线程也减减--了,最后结果就会出现负数的票数量,显然这种情况是不允许发生的,所以需要对线程进行互斥操作

互斥量的本质 : 互斥量是一个有两种状态的变量,加锁和解锁两个状态


3.1 互斥量的接口

初始化互斥量

  • 方式一 :定义静态或者全局的互斥量
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;//定义在全局区
//或者 static pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;

其实该方法也可以定义局部的,只需要有办法让其他线程拿到该互斥量的地址即可,用指针来实现

  • 方法二:动态分配
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数:
mutex:要初始化的互斥量
attr:NULL

eg:

int main(){
	pthread_mutex_t mutex lock;//声明一个互斥量
	pthread_mutex_init(&lock,nullptr);
    ......
}

让其他线程能拿到该地址即可

销毁互斥量 : 对于方法一不需要销毁

pthread_mutex_destory(pthread_mutex_t *mutex)

⭐互斥量加锁和解锁

pthread_mutex_lock(pthread_mutex_t *mutex);		//加锁
pthread_mutex_unlock(pthread_mutex_t *mutex);	//解锁

当申请加锁失败,该线程将进入阻塞状态等待

如果线程申请锁成功,那么其他线程此时处于阻塞状态。

如果线程申请锁失败,那么该线程将进入阻塞状态,直至锁被打开。


3.2 如何看待锁

  • 锁本身就是一个共享资源
  • 加锁的过程必须是原子的
  • 如果申请成功,则进入临界区,访问临界资源,没有暂时成功,那么将阻塞等待
  • 谁持有锁谁就进入临界区

加锁的汇编底层实现

  • 1.每个线程在寄存器中都有自己的上下文保存,所以假设线程A申请了锁,那么如下

  • 2.每个线程起始内部值为0,内存中初始化的锁值为1,要申请锁,那么执行汇编语句exchange该语句是一条,假设此时交换完被调走,线程拿着上下文走了,保存在寄存器中,所以,只有一个申请成功,交换后的线程的值为1此时大于0,那么代表申请锁成功

  • 3.假设线程B此时也申请锁线程B的内部值也为0,现在内存中的1已经被线程A调走了,所以无论怎么换,都失败,那么此时将阻塞挂起等待

  • 解锁是将mutex的值变为1,没有交换值的过程,然后唤醒等待的线程

3.3 可重入VS线程安全

可重入 : 同一个函数能被多个执行流进入,当当前执行流还没执行完,其他执行流再次进入,如果执行是没有出问题那么就是可重入的,如果出问题或者返回结果不同那么就是不可重入的。

线程安全 : 多个线程并发一段代码并且出现的结果都相同,不会出现不同的结果,此时线程是安全的,如果引起数据出错问题,那么线程是不安全的。

  • 可重入是线程安全的子集

  • 可重入函数是线程安全函数的一种

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

4. 死锁

**死锁的概念 : **死锁是两个或多个进程在执行过程中,持有不释放的资源,互相申请被其他进程索占有的不释放的资源而处于的一种永久等待状态。

通俗的讲在多把锁的条件下,我们持有锁,还想要对方的锁,对方也是如此,一直僵持等待,就容易造成死锁

死锁是程序员设计的时候造成的错误,并不是天然存在的,死锁会造成资源浪费和性能损耗等问题。

死锁产生的四个必要条件:

1. 互斥 : 任何时刻保证只有一个执行流在临界区执行。

2. 请求与保持 :进程已经保持了自己的资源,但又提出了新的资源请求,但该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

3. 不剥夺 : 不去强行剥夺其他进程的资源

4. 环路等待 :存在一个环路链,进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源 , .....,Pn正在等待已被P0占用的资源。例如A申请锁1-->锁2,B申请锁2--->锁1,此时假设A申请完锁1然后被调度B申请锁2,此时造成环路了就

上边的四个条件都满足才会产生死锁所以我们可以根据以上的必要条件去破坏死锁的产生。

破坏死锁的产生

首先互斥是锁的特性,是没办法破坏的

  • 破坏 请求与保持条件:请求与保持就是我自己有锁,要别人的锁要不到,那么此时要失败了,此时释放我曾经所有申请成功的所有锁,不保持与请求
  • 破坏 不剥夺条件:可以设计一个优先级高低的竞争策略,当需要的资源被其他进程占有时可以强制剥夺式的去拿
  • 破坏 环路等待条件:在申请锁的时候设计尽量保持申请锁的顺序一致,这样就可以尽可能的避免环路产生

额外补充:

线程申请锁成功,执行临界区的时候,其他线程此时阻塞等待

线程申请锁成功,正在执行临界区的时候,此时依然可以被切换走,是抱着锁被切走的,所以此时其他线程照样无法进来,等被切换回来继续执行

一个线程申请的锁,可以被另一个线程解锁,因为解锁的汇编语言是将1放入mutex中,并没有交换,但是这种情况很少发生,代码写的时候基本上不会这样写

5. 条件变量

  • 条件变量是实现线程同步与互斥的一个手段,通常条件变量与互斥锁一起使用,条件变量本身不是锁
  • 条件变量是用来阻塞一个队列,等待某一条件或者事件发生,被唤醒时则取消阻塞

1. 条件不满足,阻塞队列 2.条件满足,通知阻塞的线程开始工作

**操作函数 : **

与互斥量类似

  • 条件变量初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *attr);
// 第一个参数为要初始化条件变量的地址,第二个为设置该条件变量的属性,通常为nullptr
  • 销毁
int pthread_cond_destroy(pthread_cond_t *cond)
//参数:要销毁条件变量的地址
  • 等待条件满足
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex)

第一个参数为条件变量的地址,第二个参数为当前锁(互斥量)的地址

为什么等待条件变量的时候要需要互斥量,传入锁的地址呢?

当我条件不满足的时候,我将进入阻塞队列等待条件满足,但是此时是持有锁的,挂起的时候其他线程都拿不到锁了,造成了线程之间的阻塞,严重则一直僵持,所以当要对条件变量进行等待,此时函数将线程挂起然后将当前持有的锁给释放,当再次被唤醒的时候,再申请锁

  • 唤醒等待
int pthread_cond_signal(pthread_cond_t *cond);
//signal唤醒一个等待的线程
int pthread_cond_boardcast(pthread_cont_t *cond);
//boardcast一次性唤醒所有等待该条件变量的线程

6. 生产者消费者模型

生产者和消费者模型是通过一个容器解决两者之间强耦合的关系,在生产者和消费者之间通过一个阻塞队列来进行通信,生产者生产完数据不用去等待消费者处理,消费者不用去找生产者要数据,都是通过中间层来进行交互,阻塞队列就类似一个缓冲区平衡了消费者和生产者间的处理能力,这个阻塞队列就是用来给生产者和消费者解耦的

**生产者消费者模型的特点 : **

  • 生产者和消费者进行解耦
  • 支持生产和消费的一段时间的忙闲不均的问题
  • ⭐提高效率

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

在生产者和消费者之间添加一个阻塞队列,生产者放数据,消费者取数据

当队列为空时,消费者需要等待,此时可以使用条件变量来限制,等待条件解除,解除等待

当队列为满时,生产者等待,等待消费者取数据

为什么该模型高效呢?

当有多个线程生产以及多个线程消费时,高效之处不在与阻塞队列。

假设生产者放完数据之前需要初始化等操作,要花费一定时间,消费者获取数据之后要花时间处理拿到的数据。

但是当放之前/生产完数据后,此时其他线程也可以在这个时间进行放/取数据,然后切换线程,再回到我刚刚的线程,开始生产或者消费等操作,所以高效率的体现在线程之间可以切换,解决了忙闲不均生产之前消费之后各线程可以让线程并行执行。

7. 信号量

信号量广泛用于进程或线程间的同步和互斥,信号量本质上是⼀个非负的整数计数器,它被⽤来控制对公共资源的访问。

申请信号量的操作叫做P操作,释放信号量的操作叫做V操作,所以PV操作来协助完成同步

信号量头文件 : #include <semaphore.h>

  • 初始化信号量
int sem_init(sem_t *sem,int pshared,unsigned int value)
//参数: sem为信号量类型   
//pshared : 通常是0,0表示线程间共享
//value : 初始化信号量的值
  • 销毁信号量
int sem_destory(sem_t *sem);
  • 等待信号量 (P操作) : 等待信号量,有的话就取消等待,将信号量减去1
int sem_wait(sem_t *sem);
  • 发布信号量 (V操作) : 发布信号量,通常将信号量加1
int sem_post(sem_t *sem);

7.1 基于环形队列的生产消费模型

通过一个信号量来保证环形队列中 :

环形队列采用数组来模拟 , 用模运算来模拟环状特性

在环中只有两种情况,消费者和生产者才会碰头: 1.环形队列没数据 2.环形队列数据满

所以,通过信号量来维护特殊情况时两个位置不会超。

当队列没有数据时 消费者不能超过生产者的位置 也就是没有数据消费者不能继续

当队列数据满时 生产者不能超过消费者的位置 也就是数据满了生产者不能继续

#pragma once
#include <vector>
#include <pthread.h>
#include <semaphore.h>

template <class T>
class ringQueue
{
private:
    void P(sem_t &sem){sem_wait(&sem);}
    void V(sem_t &sem){sem_post(&sem);}

public:
    ringQueue(int gval = 5) : _cap(gval)
    {
        _rq.resize(_cap);
        pthread_mutex_init(&_pmutex, nullptr);
        pthread_mutex_init(&_cmutex, nullptr);
        sem_init(&_less, 0, 10); // 假设信号量初始为50个
        sem_init(&_have, 0, 0);
        _pindex=_cindex=0;
    }
    ~ringQueue()
    {
        pthread_mutex_destroy(&_pmutex);
        pthread_mutex_destroy(&_cmutex);
        sem_destroy(&_less);
        sem_destroy(&_have);
    }

    void push(const T &in)
    {
        P(_less);
        pthread_mutex_lock(&_pmutex);

        _rq[_pindex++]=in;
        _pindex%=_cap;

        V(_have);
        pthread_mutex_unlock(&_pmutex);
    }

    void pop(T* out){
        P(_have);
        pthread_mutex_lock(&_cmutex);

        *out=_rq[_cindex++];
        _cindex%=_cap;
        V(_less);
        pthread_mutex_unlock(&_cmutex);
    }

private:
    int _cap;                         // 环形数组的容量
    std::vector<T> _rq;                    // 用s数组来模拟实现环形阻塞队列
    int _pindex, _cindex;           // 生产者在环形阻塞队列中的下标,消费者的下标
    pthread_mutex_t _pmutex, _cmutex; // 锁: 生产者锁(_pmutex)和消费者锁(_cmutex)
    sem_t _less, _have;               // 信号量: _less表示还有多少剩余的空间,_have表示现在有多少
};

8. 线程池

线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着 监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利 用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

总的来说在运行是频繁的创建多个线程和销毁会造成速度变慢等,所以线程池是一次性创建好多个线程,供使用,是一种典型的空间换时间的方法

暴露出push的接口接受外部任务,处理任务,也可以根据其他要求来创造其他接口

代码地址 :码云(基于阻塞队列的线程池)


9. 线程安全的单例模式

单例模式是一种常见的设计模式

某些类, 只应该具有一个对象(实例), 就称之为单例.

例如一个人只能有一个妻子. 在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这 些数据.

饿汉实现方式和懒汉实现方式

饿汉实现方式和懒汉实现方式

吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.这就是饿汉方式

吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.

懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度.

懒汉就是需要用的时候再去对应的行为操作

其实平常new的堆空间,如果只先new了一批空间,操作系统实现的方式其实就是用懒汉方式来实现的

new一部分空间,操作系统首先会在进程地址空间处给你划分好一段区域,但是实际物理内存并没有给你,以及页表也没有建立映射,当检测到你用了这一段空间时,发生缺页中断,放下手里的事情,给你分配物理内存,然后建立页表映射关系,所以这就是一种典型的懒汉方式

与Linux多线程相似的内容:

Linux多线程

Linux多线程,线程的基本概念,线程库的基本原理,线程私有栈的由来,互斥与同步,互斥锁,信号量,条件变量,线程池,生产者消费者模式,基于阻塞队列/阻塞环形队列的生产者消费者模模型,单例模式,饿汉懒汉方式

它来了!真正的 python 多线程

哈喽大家好,我是咸鱼 几天前,IBM 工程师 Martin Heinz 发文表示 python 3.12 版本回引入"Per-Interpreter GIL”,有了这个 Per-Interpreter 全局解释器锁,python 就能实现真正意义上的并行/并发 我们知道,python 的多线程/进程

[转帖]Linux下fio磁盘的性能工具的部署和使用

介绍 fio 功能强大的性能测试工具, 具备多线程、报告清晰、多种引擎。 没有任何基础的,建议看完fio介绍!后继续阅读。 接下来,以ubuntu为例展示安装和使用过程。 准备条件 fio 各个版本下载各个os下fio依赖包 libaio依赖库libaio centos 备用链接 安装 安装liba

【转帖】Linux性能优化(十三)——CPU性能测试

一、CPU上下文切换测试场景 使用sysbench模拟多线程调度: sysbench --threads=10 --time=300 threads run 使用vmstat查看CPU上下文切换: cs列上下文切换次数超过150万次。 r列就绪队列长度最大达到8,超过系统CPU的个数4,存在大量的C

[转帖]Linux—CPU核数、上下文切换介绍及pidstat等命令详解

https://www.jianshu.com/p/0ae0c1153c34 关注:CodingTechWork,一起学习进步。 引言 并发编程 并发编程的目的是为了改善串行程序执行慢问题,但是,并不是启动更多线程就能够让程序执行更快。因为在并发时,容易受到软硬件资源等限制,从而导致上下文切换慢,频

[转帖]Linux内核线程kthread简介【最好的一篇!】

https://zhuanlan.zhihu.com/p/581587583 Linux内核可以看作一个服务进程(管理软硬件资源,响应用户进程的种种合理以及不合理的请求)。内核需要多个执行流并行,为了防止可能的阻塞,支持多线程是必要的。内核线程就是内核的分身,一个分身可以处理一件特定事情。内核线程的

[转帖]Apache的ab进行并发性能测试的注意点

http://www.piaoyi.org/linux/Apache-ab-test.html 正 文: ab命令原理 Apache的ab命令模拟多线程并发请求,测试服务器负载压力,也可以测试nginx、lighthttp、IIS等其它Web服务器的压力。 ab命令对发出负载的计算机要求很低,既不会

[转帖]探索惊群 ①

https://wenfh2020.com/2021/09/25/thundering-herd/ 惊群比较抽象,类似于抢红包 😁。它多出现在高性能的多进程/多线程服务中,例如:nginx。 探索惊群 系列文章将深入 Linux (5.0.1) 内核,透过 多进程模型 去剖析惊群现象、惊群原理、惊

【转帖】Linux多链路聚合技术

https://www.jianshu.com/p/dd8587ecf54f 一般而言,在单体结构的操作系统中,一块物理磁盘会接在总线设备上,并经由总线分配 PCI-Bus 号,这个时候一个 bus 往往对应一个真实可见的设备。 但在多主机的集群环境中,多个主机之间使用交换机进行通信,多台存储服务器

[转帖]Linux系统多网卡环境下的路由配置

https://www.cnblogs.com/connect/p/linux-static-route.html Linux下路由配置命令 1. 添加主机路由 route add -host 192.168.1.11 dev eth0 route add -host 192.168.1.12 gw