冰冰学习笔记:多线程

欢迎各位大佬光临本文章!!!

还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。

本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。

我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog

我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool


系列文章推荐

冰冰学习笔记:《信号》

冰冰学习笔记:《管道与共享内存》


目录

系列文章推荐

前言

1.Linux的线程概念

2.线程与进程的对比

2.1线程的优缺点

2.2线程的异常和用途

2.3进程与线程的资源划分

3.线程控制

4.线程互斥

4.1为什么需要线程互斥

4.2互斥量的函数接口

4.3深入理解申请和释放锁

4.4可重入函数和线程安全

4.5死锁概念

5.线程同步

5.1为什么需要线程同步

5.2条件变量函数

6.POSIX信号量

6.1信号量操作函数

6.2环形队列的生产消费模型

7.线程池

7.1线程池的概念

7.2线程池实现


前言

        之前我们学到过进程的概念,进程是系统调度的基本单位,每个进程都有自己独特的PCB机构以及自己独有的内存空间。当我们想要其他进程去执行任务时,我们可以创建子进程去执行父进程分配给子进程的任务,子进程与父进程的代码和数据虽然相同,但是子进程的数据是父进程的拷贝,子进程修改并不影响父进程。今天我们说讲的线程类似于子进程,也是一个执行流,用来执行不同任务,但是与进程有所区别。

1.Linux的线程概念

        在将Linux的线程之前,我们再重新认识以下进程的概念。

        每一个进程都有自己独有的task_struct结构体,结构体中具备该进程自己的虚拟空间地址,并通过页表映射到物理内存中。我们之前并没有详细讲解页表的存储方式,页表是如何映射这么多的地址空间的呢?

        如果页表一一映射物理内存地址,那么页表非常大,内存根本无法存储。因此页表采用了分成的映射方式。物理内存实际上是按照4kb的单位进行划分的,每一小块内存称之为页框,磁盘上的内存也是按照4kb划分,称之为页帧。页表想要映射这些地址,显然是无法存储的。

        页表分为两层进行映射,页表将4字节32个比特位划分为3组,第一组为前10个比特位,存储在一级页表中。中间10个比特位映射到2级页表中。这样一个地址可以根据前10个位找到一级页表,通过一级页表找到对应的二级页表,根据中间10个比特位就能找到物理内存中对应的哪个4kb空间。最后12个比特位则是每个4kb空间的偏移量,通过每个偏移量则能找到每个地址。

        因此CPU在调度时,找到进程的task_struct结构体,通过结构体访问虚拟地址,然后通过页表的映射最终访问到物理内存中的数据。而当我们创建子进程时,子进程会拷贝父进程的task_struct,虚拟地址空间,页表,并重新映射自己的物理内存。CPU调度子进程从而通过映射后找到的是子进程对应的物理地址中的数据。

        而此时我们发现CPU调度时不会管你的虚拟地址空间是不是自己的,我只需要你的task_struct结构体即可

        其实每一个进程中的task_struct结构体都称之为一个线程,即在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。因此Linux的线程与进程没有多大的区别,只不过每个进程有自己独有的虚拟地址空间,而多线程则共享一个进程中的虚拟地址空间,即线程在进程的地址空间内运行。

        所以之前我们学的进程实际上是内部只有一个执行流的进程,而内部具备多个执行流时,每个执行流就叫做线程。CPU只管调度task_struct,并不管具备几个执行流。所以我们看到,线程实际上才是OS调度的基本单位

        Linux没有真正意义上的线程结构,有的只是轻量级的进程,因此Linux并不能给我们提供线程的相关接口,只能提供轻量级的进程接口。但是Linux为了方便使用,在用户层实现了一套多线程的方案,即pthread库。

2.线程与进程的对比

2.1线程的优缺点

线程的优点:

(1)创建一个新线程的代价要比创建一个新进程小得多

(2)与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。

(3)线程占用的资源要比进程少很多

(4)能充分利用多处理器的可并行数量

(5)在等待慢速I/O操作结束的同时,程序可执行其他的计算任务

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

(7)I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

线程的缺点:

        线程有可能照成性能损失,如果计算密集型 线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

        程序健壮性降低,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。

        线程缺乏访问控制,进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

        线程的编写难度提高,编写与调试一个多线程程序比单线程程序困难得多。

2.2线程的异常和用途

        单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

        但是合理的使用多线程,能提高CPU密集型程序的执行效率,合理的使用多线程,能提高IO密集型程序的用户体验。

2.3进程与线程的资源划分

        进程的多个线程中绝大多数的资源都是共享的,如代码段,数据段,或者定义的一个函数、全局变量,各个线程都能调用。线程还共享文件描述符表,每种信号的处理方式,当前工作目录,用户id和组id。

        但是进程是资源分配的基本单位,线程是调度的基本单位,线程也具备自己的数据,如线程ID,一组寄存器,栈,信号屏蔽字,errno,调度优先级

3.线程控制

        与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。要使用这些函数库,要通过引入头文件<pthread.h> 。链接这些线程函数库时要使用编译器命令的   “-lpthread”选项。

(1)pthread_create:创建新线程

头文件:#include<pthread>

函数体: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;失败返回错误码。

注意:

        传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。

        pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回

        pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误, 建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小

        下面我们通过代码创建多个线程,并验证多线程是否在同一个进程中:

void* handler(void* name)
{
    const string s=(char*)name;
    while(true)
    {
        cout<<s<<"进程id为:"<<getpid()<<endl;
        sleep(1);
    }
}
int main()
{
    pthread_t tid[5];
    char name[64];
    for(int i=1;i<=5;i++)
    {
        snprintf(name,sizeof(name),"%s-%d","thread",i);
        //创建多个线程
        pthread_create(&tid[i-1],nullptr,handler,(void*)name);
        sleep(1);
    }
    while(true)
    {
        cout<<"主线程,pid: "<<getpid()<<endl;
        sleep(5);
    }
    return 0;
}

        结果发现,每个线程的pid与主进程的pid完全相同,这意味着线程在进程内部。 

        当线程出现野指针,除零错误时,进程会不会崩溃呢?

void* handler(void* name)
{
    const string s=(char*)name;
    int count=0;
    while(true)
    {
        cout<<s<<"在运行:"<<count<<endl;
        count++;
        sleep(1);
        if(count==5)
        {
            char* p=nullptr;
            *p='a';//野指针问题
        }
    }
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,handler,(void*)"thread-1");
    while(true)
    {
        cout<<"主进程在运行"<<endl;
        sleep(1);
    }
}

        在线程执行5秒后,出现野指针错误,此时我们发现一直在运行的两个线程都会退出。线程虽然pid相同,但是每个线程都有自己独特的LWP(轻量级进程)号,CPU通过LWP进行调度。

        而且我们还发现,线程的执行并没有固定的顺序,例如在第一个例子中,线程完全没有顺序,这就说明,线程的运行顺序和调度器有关。线程一旦异常,都可能导致整个进程体系退出,线程在创建并执行的时候线程也是需要等待的,如果不等待也会出现类似于僵尸进程的问题,导致内存泄漏。

        线程之间对于全局变量也是共享的,一个线程更改,其他线程的数据也会更改,如果想让全局变量每个线程私有,那么需要增加__thread进行修饰。

int g_val=0;
void* handler(void* num)
{
    while(true)
    {
        cout<<"新线程g_val: "<<g_val++<<endl;
        sleep(1);
    }
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,handler,(void*)"thread-1");
    while(true)
    {
        cout<<"主线程g_val: "<<g_val<<endl;
        sleep(1);
    }
}

新线程对g_val进行更改,此时两个线程都会更改:

当使用__thread修饰后,新进程更改,不影响主线程:

(2)pthread_join:线程等待

头文件:#include<pthread.h>

函数体:int pthread_join(pthread_t thread, void **value_ptr);

参数:thread:线程ID

           value_ptr:它指向一个指针,后者指向线程的返回值

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

        为什么需要线程等待呢?原因在于已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。 创建新的线程不会复用刚才退出线程的地址空间。

        线程在创建后会去执行线程对应的功能函数,该函数是具备返回值的,那么函数的返回值返回给谁呢?其实返回值就返回给了创建线程的进程,并且通过pthread_join函数的第二个参数获取。线程等待是默认以阻塞的方式进行等待,如果线程不退出,就会一直等待。

用下面的代码进行验证:

void* handler(void* name)
{
    const string s=(char*)name;
    int count=0;
    int* arr=new int[5];
    while(true)
    {
        cout<<s<<"在运行:"<<count<<endl;
        arr[count]=count++;
        if(count==5)
            break;
        sleep(1);
    }
    return (void*)arr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,handler,(void*)"thread-1");
    int *arr;
    pthread_join(tid,(void**)&arr);//默认阻塞等待
    cout<<"主线程获取返回值"<<endl;
    for(int i=0;i<5;i++)
    {
        cout<<arr[i]<<" ";
    }
    cout<<endl;
}

主线程获取到返回值,并打印:

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

        1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。

        2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数 PTHREAD_ CANCELED。

        3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。

        4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

(3)线程终止:pthread_exit

头文件:#include<pthread.h>

函数体:void pthread_exit(void *value_ptr);

参数:value_ptr:value_ptr不要指向一个局部变量

返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

        线程的终止函数不能直接调用exit函数,该函数意味着进程的终止,如果在线程退出时调用,整个进程就会退出。

(4)线程取消:pthread_cancel

头文件:#include<pthread.h>

函数体:int pthread_cancel(pthread_t thread);

参数:thread:线程ID

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

        线程取消时,一定要主线程取消新线程,并且确保新线程已经开始运行了。 

void* handler(void* name)
{
    const string s=(char*)name;
    int count=0;
    while(true)
    {
        cout<<s<<"运行中: "<<count<<endl;
        count++;
        if(count==5)
            break;
        sleep(1);
    }
    cout<<"线程终止"<<endl;
    pthread_exit((void*)2);
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,handler,(void*)"thread-1");
    sleep(3);
    pthread_cancel(tid);
    cout<<"3秒后线程取消"<<endl;
    pthread_join(tid,nullptr);//默认阻塞等待
}

(5)获取线程id: pthread_self

头文件:#include<pthread.h>

函数体:pthread_t pthread_self(void);

参数:无参

返回值:返回当前线程的线程id

        对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质 就是一个进程地址空间上的一个地址。线程之间的栈是不共享的,那么每个线程的栈是怎么维护的呢?

        其实pthread库中给线程维护了一个独立的栈空间,而该空间的地址就是pthread_t类型的线程id。

 (6)线程分离:pthread_detach

头文件:#include<pthread.h>

函数体:int pthread_detach(pthread_t thread);

参数:线程id

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

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

4.线程互斥

4.1为什么需要线程互斥

在了解线程互斥之前,我们先复习之前讲过的一些概念:

(1)临界资源:多线程执行流共享的资源就叫做临界资源

(2)临界区:每个线程内部,访问临界资源的代码,就叫做临界区

(3)互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

(4)原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

        线程互斥主要解决的就是线程之间对临界资源互相访问,因为线程调度时间不同而造成的数据混乱问题。大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个 线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

        下面的抢票例子中,多线程之间访问同一个全局变量会出现票数多卖的情况:

int tickets=1000;
void *getTickets(void *args)
{
    (void)args;
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else{
            break;
        }
    }
    return nullptr;
}
int main()
{
    pthread_t tid[5];
    char name[64];
    for(int i=1;i<=5;i++)
    {
        //创建多个线程
        pthread_create(&tid[i-1],nullptr,getTickets,nullptr);
    }
    for(int i=0;i<5;i++)
    {
        pthread_join(tid[i],nullptr);
    }
    return 0;
}

        我们发现有些线程会出现抢到负数票的情况。

        这其中的原因就是多线程对不加保护的临界变量进行并发执行的问题。票数tickets进行自减的操作看似只有一行代码,实际上对应三条汇编指令,因此tickets的自减操作并非原子操作。CPU对tickets的操作需要分为三步:第一步,load :将共享变量tickets从内存加载到寄存器中;第二步,update : 更新寄存器里面的值,执行-1操作;第三步:store :将新值,从寄存器写回共享变量tickets的内存地址。这三步在一个线程执行过程中会有可能在任意一步进行切走,执行另外的线程,其他线程又会访问该变量。

        多个线程经过这种不加保护的操作后,tickets出现混乱,从而导致票数多卖。

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

(1)代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

(2)如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

(3)如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

        本质上我们需要线程独立的访问临界数据区,需要一把锁将该区域进行锁住,Linux上提供的这把锁叫互斥量。

4.2互斥量的函数接口

(1)创建互斥量

静态分配:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

        锁是全局的变量时,使用宏PTHREAD_MUTEX_INITIALIZER进行初始化。

动态分配:当锁是局部变量时,需要调用初始化函数pthread_mutex_init进行初始化。

头文件:#include<pthread.h>

函数体:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

参数:mutex:要初始化的互斥量

           attr:NULL

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

(2)销毁互斥量

头文件:#include<pthread.h>

函数体:int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:mutex:要销毁的互斥量

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

(3)加锁和解锁

头文件:#include<pthread.h>

函数体:int pthread_mutex_lock(pthread_mutex_t *mutex);

              int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:mutex:要加锁或者解锁的互斥量

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

        此时将抢票逻辑进行加锁控制,此时就不会出现数据紊乱的问题了。

void *getTickets(void *mtx)
{
    
    while(true)
    {
        pthread_mutex_lock((pthread_mutex_t*)mtx);
        if(tickets > 0)
        {
            usleep(rand()%1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
            pthread_mutex_unlock((pthread_mutex_t*)mtx);
        }
        else{
            pthread_mutex_unlock((pthread_mutex_t*)mtx);
            break;
        }
    }
    usleep(rand()%200000);
    return nullptr;
}

        由此我们可以得出,在进行加锁之后,线程之间执行临界区的代码时是串行的,那么加了锁之后线程在临界区还是会进行切换的,但是此时的切换是带着锁进行切换的,其他线程想要访问临界区的资源还是需要先申请锁,锁无法申请成功,所以此时还是无法访问临界资源,从而确保了临界区的资源的安全性。注意:加锁的粒度需要越细越好。

4.3深入理解申请和释放锁

        现在我们明白了,临界区的代码添加锁后就能保证多个线程访问共享数据的唯一性,也就是这把锁是每个线程都能看到的资源。那么这把锁不也是一种共享资源吗?那么谁来保证锁的安全呢?换句话说,申请和释放锁也必须是原子性的。这就陷入了循环死穴。

        其实,锁的原子性是由锁本身来保证的。

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

        而lock和unlock的伪代码如下所示,我们进行分析:

        首先我们要知道,多个线程共享CPU寄存器的空间,但是寄存器里面的内容是每个线程的上下文数据,是私有的,在被切换时会带走。

        整个过程中,mtx的1全程只有一个,线程A,B都是通过交换得到的,线程A交换走,线程B就不会得到,从而保证了原子性 。

4.4可重入函数和线程安全

线程安全:

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

重入:

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

线程不安全的情况:

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

线程安全的情况:

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

不可重入的情况:

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

可重入的情况:

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

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

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

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

4.5死锁概念

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

        多个锁的申请和释放会造成死锁,例如线程A申请锁1成功后,去申请锁2,发现锁2被线程B申请了,线程A只能挂起等待,而线程B在执行过程中,又去申请锁1,发现线程A申请了,只能挂起等待,此时两个线程陷入死锁,互相等待。

        一把锁也有可能造成死锁,例如线程A申请锁之后没有释放,再去申请时就会造成死锁。

死锁四个必要条件:

(1)互斥条件:一个资源每次只能被一个执行流使用

(2)请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

(3)不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺

(4)循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁:

(1)破坏死锁的四个必要条件

(2)加锁顺序一致

(3)避免锁未释放的场景

(4)资源一次性分配

避免死锁算法:死锁检测算法,银行家算法。

5.线程同步

5.1为什么需要线程同步

        通过互斥锁的使用,我们能够确保临界资源的安全。但是线程在使用互斥锁时还会带了一个问题,如果一个线程频繁的申请互斥锁,那么其他的线程就得等待,线程的等待没有秩序,谁抢到就是谁的。线程在申请临界资源之前一定要先对临界资源的存在做出检测,而对临界资源检测的本质也是访问临界资源,这就意味着对临界资源的检测也一定需要在加锁和解锁之间。那么那些等待临界资源的线程就必然需要频繁的申请和释放锁,带来极大的资源浪费。

        线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。存在的目的就是为了解决这些线程访问临界资源合理性的问题

        竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。

        如果我们能够让线程在资源不就绪的时候进行等待,而不是频繁的进行临界资源的申请,等到临界资源满足条件就绪了,就通知对应的线程,让其来进行资源的申请和访问。这就需要条件变量。

5.2条件变量函数

(1)条件变量的初始化函数

        当定义全局的条件变量时,可以使用PTHREAD_COND_INITIALIZER进行初始化。

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

        当条件变量为局部变量时,需要调用初始化函数进行初始化。

头文件:#include<pthread.h>

函数体:int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

参数:

        cond:要初始化的条件变量

        attr:NULL

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

(2)销毁函数

头文件:#include<pthread.h>

函数体:int pthread_cond_destroy(pthread_cond_t *cond);

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

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

(3)等待条件函数

头文件:#include<pthread.h>

函数体:int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

参数:cond:要在这个条件变量上等待

          mutex:互斥量

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

        为什么 pthread_cond_wait 需要互斥量?

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

(4)唤醒等待

头文件:#include<pthread.h>

函数体:int pthread_cond_broadcast(pthread_cond_t *cond);//一次唤醒一批线程

              int pthread_cond_signal(pthread_cond_t *cond);//一次唤醒一个线程

参数:cond:要唤醒的条件变量

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

代码练习:生产者消费者模型

6.POSIX信号量

        前面的章节中我们提到过信号量,并且将其视为一个“计数器”。现在我们深入了解一下信号量。这里我们所说的信号量是POSIX信号量,它可以支持线程同步。我们都知道在访问共享资源的时候,对于临界区的资源必须要确保只有一个执行流来进行访问,因为只有这样才是安全的。但是有时临界区具备多种临界资源,每个线程想要获取的或许是不同的,如果都要加锁解锁来访问,效率必然降低。因此,我们可以在访问前进行申请,如果资源具备,那线程就直接拿走,其他线程同时也可以申请,就如同我们买点影票一样,只有里面的资源不再具备,此时线程申请就会失败,哪个线程都一样,都必须等待。只有线程访问的资源相同时才进行加速解锁操作。

        所以在对资源进行使用时我们先进行申请,就是信号量的P操作,使用完毕后对其进行释放,就是信号量的V操作。具体的函数如下:

6.1信号量操作函数

(1)初始化信号量

头文件:#include <semaphore.h> 

函数体:int sem_init(sem_t *sem, int pshared, unsigned int value); 

参数:

               pshared:0表示线程间共享,非零表示进程间共享  

               value:信号量初始值 

返回值:  成功返回0,失败返回-1,并且设置错误码

(2)销毁信号量

头文件:#include <semaphore.h> 

函数体:int sem_destroy(sem_t *sem); 

参数:要销毁的信号量

返回值:  成功返回0,失败返回-1,并且设置错误码

(3)等待信号量

头文件:#include <semaphore.h> 

函数体:int sem_wait(sem_t *sem);  

参数:等待的信号量

返回值:  成功返回0,失败返回-1,并且设置错误码

(4)发布信号量 

头文件:#include <semaphore.h> 

函数体:int sem_post(sem_t *sem);

参数:要发布的信号量

返回值:  成功返回0,失败返回-1,并且设置错误码

6.2环形队列的生产消费模型

        环形队列中一个线程进行数据的生产,一个线程进行数据的消费,如果此时两个线程访问的并非同一个数据,那么就不会出现线程安全问题,只有在同时访问同一个数据的时候才会出现数据二义性的问题。

        例如在下面的情况,线程A在生产了数据-4,线程B正在拿走数据6,此时两个线程并不干扰,不需要加锁来进行保护。当环形队列中数据为空时,线程B想要消费就必须等待线程A进行生产,环形队列数据满了时,线程A想生产就必须让线程B进行消费之后才能生产。

         所以,线程A扮演的生产者需要的是空间资源,具备空间资源才能生产数据。线程B扮演的消费者需要数据资源,消费了数据资源才能具备空间。所以此时我们就可以引入信号量进行生产,当生产者进行生产时,先去申请空间资源,申请成功则空间资源信号量自减,并进行数据生产,生产完数据后,将数据资源的信号量进行自增,申请失败则需要等待空间资源就绪。同理,消费者消费时,也要申请数据资源,成功则数据资源自减,失败则说明没有数据可以消费,需要等待生产者进行生产。消费成功后,空间资源就会留出,空间资源自增。

        因此我们实现的生产消费模型要满足下列规则:

        (1)生产者和消费者同时指定一个位置时,则说明此时属于互斥状态,必须有一个需要等待资源就绪,哪个身份进行等待取决于此时哪个资源没有就绪。

        (2)生产者,消费者不指向同一个位置,此时可以直接并发执行。

        (3)并发执行要满足下列条件,生产者不能将消费者套圈;消费者不能超过生产者;队列空间为空,生产者先运行;队列空间为满,消费者先运行。

const static int g_num_default = 5;
template <class T>
class RingQueue
{
public:
    RingQueue(const int num = g_num_default)
        : num_(num),
          rqueue_(num),
          pro_step_(0),
          con_step_(0),
          data_sem_(0),
          space_sem_(num),
          p_mutex(new pthread_mutex_t()),
          c_mutex(new pthread_mutex_t())
    {}
    ~RingQueue()
    {}
    void push(const T &x)
    {
        space_sem_.P(); // 申请信号量
        {
            LockGuard lockGuard(&p_mutex);//RAII
            rqueue_[pro_step_++] = x;
            pro_step_ %= num_;
        }
        data_sem_.V();
    }
    void pop(T *y)
    {
        data_sem_.P();
        {
            LockGuard lockGuard(&c_mutex);
            *y = rqueue_[con_step_++];
            con_step_ %= num_;
        }
        space_sem_.V();
    }

private:
    std::vector<T> rqueue_; // 模拟队列
    int num_;               // 元素个数
    int pro_step_;          // 生产下标
    int con_step_;          // 消费下标

    Mutex p_mutex; // 锁
    Mutex c_mutex;
    Sem data_sem_;  // 数据信号量
    Sem space_sem_; // 空间信号量
};

具体代码连接如下:基于信号量的环形队列

7.线程池

7.1线程池的概念

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

        线程池的应用场景:

        (1)  需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个 Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。 

        (2)对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。

        (3) 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限, 出现错误。

7.2线程池实现

       (1)采用单例模式

        线程池在启动后,我们整个进程运行阶段只希望采用一份线程池即可,这就需要我们使用单例模式来进行。单例模式常用的有懒汉模式饿汉模式。饿汉模式往往在进程启动的时候进行创建,而饿汉模式则在第一次使用时才进行创建。这里我们采用饿汉模式实现。

        实现单例模式就需要将代码进行防止拷贝,赋值等操作,这就需要将构造函数私有化,并且删除拷贝和赋值函数。在类内我们需要一个线程池的静态指针,我们在类外进行定义,并且提供以一个类内获取线程池对象指针的接口,该函数也是静态成员函数;在我们需要使用的时候直接突破类域进行访问获取即可。

        但是在多线程情况下,单例模式并非是线程安全的,例如有多个线程去调用获取单例接口的函数,那么就可能获得多个线程池。

         这里我们采取了两成判断进行加锁的安全访问函数,多个线程在进行访问时,只有一个线程能获得锁并进行单例的获取,在该线程申请锁时,其他线程即便进入外部的if语句也无法访问内部的。在单例模式创建完毕后,once_tp_不在是空指针,即便多个线程再次访问,也不会再去申请释放锁之后再进行判断,而是依靠外部的判断直接返回,减少申请释放锁的消耗。

    static ThreadPool<T> *GetOnceTP(int num = g_nums_default)
    {
        if (once_tp_ == nullptr)
        {
            pthread_mutex_lock(&st_mutex);
            if (once_tp_ == nullptr)
            {
                once_tp_ = new ThreadPool<T>(num);
            }
            pthread_mutex_unlock(&st_mutex);
        }
        return once_tp_;
    }

       (2)执行任务的函数要使用静态成员函数 

        为什么执行具体任务时我们要采用静态成员函数呢?原因在于我们将任务函数需要传递给新线程去执行,并且需要转递任务函数的参数。如果任务函数为类内成员,那么该函数的第一个参数默认为this指针,在进行传递和使用的时候会报错。

        但是如果函数采用静态成员函数,那么就不能访问类内的成员了,那么我们怎么使用类内成员呢?这里我们采用了在创建线程时,传递任务函数的参数实际上传递的是线程池本身,即this指针,这样我们在实现任务函数的时候,就可以在内部进行解引用并访问。

 线程池代码如下:线程池的实现

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

bingbing~bang

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

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

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

打赏作者

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

抵扣说明:

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

余额充值