Linux学习笔记——多线程

文章目录

  • 补充知识
  • Linux线程概念
    • 线程的优点
    • 线程的缺点
    • 线程异常
    • 线程用途
    • 多进程的应用场景
  • Linux进程VS线程
    • 重新理解进程
    • 进程和线程
    • 线程共享的进程资源和环境
    • 为什么线程切换的成本更低
    • 进程和线程的关系
  • Linux线程控制
    • POSIX线程库
    • 创建线程
    • 线程ID及进程地址空间布局
    • 线程终止
    • 线程等待
    • 线程分离
  • 线程互斥
    • 进程线程间互斥相关概念
    • 线程安全
    • 线程不安全
    • 线程不安全代码实现
    • 互斥量mutex
    • 互斥量的接口
    • 互斥锁的使用
    • 互斥量常见问题
    • 互斥锁的原理
    • 死锁
  • 线程同步
    • 条件变量
    • 条件变量函数
    • 线程同步案例
    • 条件变量的等待接口参数为什么需要互斥锁
    • 线程等待的时候,被唤醒之后,需要做什么事情
    • 当被唤醒时从哪里醒来呢?
  • 生产者消费者模型
    • 生产者消费者模型的概念
    • 生产者消费者模型优点
    • 基于BlockingQueue的生产者消费者模型
    • 生产者与消费者模型代码实现
  • POSIX信号量
    • POSIX信号量的概念
    • POSIX信号量函数
    • 基于环形队列的生产消费模型
  • 线程池
    • 线程池概念
    • 线程池作用
    • 线程池应用场景
  • 线程安全的单例模式
    • 单例模式概念
    • 单例模式特点
    • 饿汉方式实现单例模式
    • 懒汉方式实现单例模式
  • STL智能指针和线程安全
  • 其他常见的各种锁

补充知识

vm_area_struct

   进程的虚拟地址空间中,堆分配的空间在逻辑地址上是连续的,但在物理地址上是不连续的。OS使用vm_area_struct结构体来细粒度划分进程中的堆区资源,vm_area_struct结构体中包含着标记位start和end,来描述堆空间,采用双链表来组织堆空间数据。


页帧和页框

   可执行程序就是一个文件,用户的可执行程序是按照地址空间的方式进行编译的,可执行程序按照区域被划分为了以4KB为单位的区域,这每4KB的区域称为一个页帧,物理内存每4KB的块称为一个页框。程序加载到物理内存的过程就是页帧加载到页框里。当然OS也要管理物理内存中的每一个页框空间,那么就需要一个结构体page来描述页框,在通过结构体数组来管理所有的物理内存。


页表

   进程的虚拟地址空间中,采用页表来映射到物理内存,在32位机器下,一个地址就有4字节,也就是32位。OS肯定没有那么大空间来存储所有地址的页表。OS把32位空间进行划分,前十个位对应一级页表key值,映射的value值可以找到二级页表,再用中间的十个位来当作二级页表的key值,映射的value值找到物理地址的起始空间,最后12个位来找到页内偏移量,锁定物理地址空间。


Linux线程概念

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

Linux学习笔记——多线程_第1张图片

   线程是在进程内部执行,是OS调度的基本单位。如上图所示,每一个task_struct都可以称为是一个线程,线程在进程内部(进程的地址空间内)执行。线程是OS调度的基本单位,主要是因为CPU其实不关心执行流是进程还是线程,CPU只关心PCB,只认识task_struct。

   Linux特殊的方案,通过一定的技术手段,将当前进程的资源,以一定的方式划分给了不同的task_struct,从而实现了轻量化进程。Linux没有真正意义上的线程结构,而是用进程pcb模拟实现线程的。并且Linux并没有给用户直接提供线程的相关接口,只是提供了轻量级进程的接口,且在用户层实现了一套用户层多线程方案,以pthread原生线程库的方式提供给用户使用。


线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多。
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
  • 线程占用的资源要比进程少很多。
  • 能充分利用多处理器的可并行数量。
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
  • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
    Linux学习笔记——多线程_第2张图片

线程的缺点

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

线程异常

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

线程用途

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

多进程的应用场景

Linux学习笔记——多线程_第3张图片


Linux进程VS线程

重新理解进程

   在用户视角,进程包含内核数据结构(所有的PCB)和该进程对应的代码和数据。从内核视角,进程承担分配系统资源的基本实体。


进程和线程

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位
  • 线程共享进程数据,但也拥有自己的一部分数据: 线程ID、一组寄存器、errno、信号屏蔽字、调度优先级。其中最重要的数据是栈和寄存器。私有栈是为了保存临时变量,便于函数调用等操作。私有寄存器是为了方便线程切换,保存上下文。

Linux学习笔记——多线程_第4张图片


线程共享的进程资源和环境

   进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的。如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。除此之外,各线程还共享文件描述符表、每种信号的处理方式、当前工作目录、用户id和组id。


为什么线程切换的成本更低

   CPU内部有L1~L3级cache,可以对内存的代码和数据,根据局部性原理预读进CPU内部,如果进程切换,cache就立即失效,新的进程过来只能重新缓存。并且,线程切换的时候,地址空间和页表都不需要切换!


进程和线程的关系

Linux学习笔记——多线程_第5张图片
   单进程就是具有一个线程执行流的进程。


Linux线程控制

POSIX线程库

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

创建线程

功能:创建一个新的线程
原型: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变量的开销更小。

mythead.cpp

#include 
#include 
#include 
#include 
#include 
using namespace std;

int x = 100;

void show(const string &name)
{
    cout << name << ", pid: " << getpid() << " " << x << "\n" <<  endl;
}

void* threadRun(void* args)
{
    const string name = (char* )args;
    while (true)
    {
        show(name);
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5];
    char name[64];
    for (int i = 0; i < 5; i++)
    {
        snprintf(name, sizeof name, "%s-%d", "thread", i);
        pthread_create(tid + i, nullptr, threadRun, (void *)name);
        sleep(1); // 缓解传参的bug
    }

    while (true)
    {
        cout << "main thread, pid: " << getpid() << endl;
        sleep(3);
    }
}

makefile

 1	mythread:mythread.cpp
 2		g++ $^ -o $@ -lpthread
 3	.PHONY:clean
 4	clean:
 5		rm -f mythread

在这里插入图片描述


_ _thread
_ _thread 修饰全局变量,带来的结果就是让每一个线程各自拥有一个全局的变量 – 线程的局部存储


   在命令行输入$ ps -aL可以查看进程的PID和LWP ,LWP用来标识不同的线程,而不同的线程因为是相同的进程,所以PID相同。
Linux学习笔记——多线程_第6张图片


线程ID及进程地址空间布局

   pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的LWP不是一回事。前面讲的线程LWP属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。

  pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID,对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

pthread_t pthread_self(void);

   前面提到,栈区是每一个线程独占的,那么所有的线程都指向同一块虚拟地址空间,如何保证每个线程独占一个栈区呢? 一定是用户层的pthread库提供的。pthread库放到磁盘上,经过页表映射到虚拟地址空间的共享区。pthread库会在共享区为除了主线程外的所有线程依次创建一个结构体来保存栈区的信息,并用pthread_t类型ID保存该线程在进程地址空间上的一个地址。

Linux学习笔记——多线程_第7张图片


线程终止

如果只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
void* thread_run(void* arg)
{
	cout<<(char*)arg<<pthread_self()<<" pid:"<<getpid()<<endl;
	sleep(1);
	return (void*)10;
}
int main()
{
	pthread_t tid;
	int ret= pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
	if(ret != 0)
	{
	  return -1;
	}
	while(1)
	{
		 cout<<"i am main:"<<pthread_self()<<" pid:"<<getpid()<<endl;
		 sleep(2);
	}
	void* tmp=NULL;
	pthread_join(tid,&tmp);
	cout<<"thread eixt code:"<<(long long) tmp<<endl;
	return 0;
}

  1. 线程可以调用pthread_ exit终止自己。

pthread_exit函数

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

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

void* thread_run(void* arg)
{	       
	cout<<(char*)arg<<pthread_self()<<" pid:"<<getpid()<<endl;
	sleep(1);
	pthread_exit((void*)10);
}

  1. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

pthread_cancel函数

功能:取消一个执行中的线程
原型:int pthread_cancel(pthread_t thread);
参数:thread:线程ID
返回值:成功返回0;失败返回错误码
	int main()
16	{
17	    pthread_t tid;
18	    int ret=0;
19	    ret= pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
20	    if(ret!=0)
21	    {
22	        return -1;
23	    }   
24	    
25	    sleep(10);
26	    pthread_cancel(tid);
27	    cout<<"new thread "<<tid<<" be cancled!"<<endl;
28	    void* tmp=NULL;
29	    pthread_join(tid,&tmp);
30	    cout<<"thread qiut code:"<<(long long )ret<<endl;
31	    return 0;
32	}

线程等待

为什么需要线程等待?

  已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,会产生类似于僵尸进程的场景,创建新的线程不会复用刚才退出线程的地址空间,造成内存泄漏。另外,线程的入口函数是有返回值的,有时候需要查看这个结果。

功能:等待线程结束
原型:int pthread_join(pthread_t thread, void **value_ptr);
参数:thread:线程ID,value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

  调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。

总结如下:

  • 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  • 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。#define PTHREAD_CANCELED ((void *) -1)
  • 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  • 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

线程分离

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

int pthread_detach(pthread_t thread);

  可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离,joinable和分离是冲突的,一个线程不能既是joinable又是分离的

pthread_detach(pthread_self());

案例:

void* thread_run(void* arg)
{
    pthread_detach(pthread_self());
    printf("%s\n", (char*)arg);
    return NULL;
} 
int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, thread_run,"thread1 run...");
    int ret = 0;
    sleep(1);   //很重要,要让线程先分离,再等待
    if ( pthread_join(tid, NULL ) == 0 ) 
    {
        printf("pthread wait success\n");
        ret = 0;
    } 
    else 
    {
        printf("pthread wait failed\n");
        ret = 1;
    } 
    return ret;
}

线程互斥

进程线程间互斥相关概念

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

线程安全

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

线程不安全

Linux学习笔记——多线程_第8张图片

  正常情况,假设定义一个变量 i 这个变量 i 一定是保存在内存的栈当中的,我们要对这个变量 i 进行计算的时候,是CPU(两大核心功能:算术运算和逻辑运算)来计算的,假设要对变量 i = 10 进行 +1 操作,首先要将内存栈中的 i 的值为 10 告知给寄存器,此时,寄存器中就有一个值 10,让后让CPU对寄存器中的这个 10 进行 +1 操作,CPU +1 操作完毕后,将结果 11 回写到寄存器当中,此时寄存器中的值被改为 11,然后将寄存器中的值回写到内存当中,此时 i 的值为 11。
Linux学习笔记——多线程_第9张图片

  假设有两个线程,线程A和线程B,线程A和线程B都想对全局变量 i 进行++。线程A从内存中把全局变量 i = 10 读到寄存器当中,此时,线程A的时间片到了,线程A被切换出来了,线程A的上下文信息中保存的是寄存器中的i = 10,程序计数器中保存的是下一条即将要执行的 ++ 指令,若此时线程B获取了CPU资源,也想对全局变量 i 进行 ++ 操作,因为此时线程A并未将运算结果返回到内存当中,所以线程B从内存当中读到的全局变量 i 的值还是10,然后将 i 的值读到寄存器中,然后再在CPU中进行 ++ 操作,然后将 ++ 后的结果 11,回写到寄存器,寄存器再回写到内存,此时内存当中 i 的值已经被线程B机型 ++ 后改为了 11,然后线程B将CPU资源让出来,此时线程A再切换回来的时候,它要执行的下一条指令是程序计数器中保存的对 i 进行 ++ 操作 ,而线程A此时 ++ 的 i 的值是从上下文信息中获取的,上下文信息中此时的 i = 10 ,此时线程A在CPU中完成对 i 的 ++ 操作,然后将结果 11 回写给寄存器,然后由寄存器再回写给内存,此时内存中的 i 被线程B改为了 11,虽然 ,线程A和线程B都对全局变量 i 进行了 ++ ,按理说最终全局变量 i 的值应该为12,而此时全局变量 i 的值却为11。线程A对全局变量 i 加了一次,线程B也对全局变量 i 加了一次,而此时,全局变量的值为 11 而不是 12,由此就产生了多个线程同时操作临界资源的时候有可能产生二义性问题/线程不安全现象。

线程不安全代码实现

#include 
#include 
#include 
using namespace std;

//如果多线程访问同一个全局变量,并对它进行数据计算,多线程会互相影响吗?
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题!

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 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);
}

Linux学习笔记——多线程_第10张图片

  如上图所示,我们可以看到最后一个线程都拿到了第-1张票,而不是0,这就导致二义性,即线程不安全现象。

取出ticket- -部分的汇编代码

objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>

ticket- -操作并不是原子操作,而是对应三条汇编指令

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

互斥量mutex

  大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,此时变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题。

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

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

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

Linux学习笔记——多线程_第11张图片


互斥量的接口

初始化互斥量

  • 方法1,静态分配 :pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;在LinuxThreads实现中,pthread_mutex_t是一个结构体,而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调用会陷入阻塞,等待互斥量解锁。

使用互斥量解决抢票问题

#include 
#include 
#include 
#include 
#include 
#include 

int ticket = 10000; //临界资源

//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg)
{
    char *id = (char*)arg;
    while (1) 
    {
        pthread_mutex_lock(&mutex);//加锁保护:加锁的时候,一定要保证加锁的粒度,越小越好!!
        //临界区
        if (ticket > 0) 
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        } 
        else 
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
} 
int main()
{
    pthread_t t1, t2, t3;
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, NULL);
    pthread_create(&t1, NULL, route, (void*)"thread 1");
    pthread_create(&t2, NULL, route, (void*)"thread 2");
    pthread_create(&t3, NULL, route, (void*)"thread 3");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_mutex_destroy(&mutex);
}

Linux学习笔记——多线程_第12张图片
  可以看出抢完最后一张票就结束了,即线程安全了。


互斥锁的使用

  • 什么时候使用初始化互斥锁? 先初始化互斥锁,在创建线程
  • 什么时候使用销毁互斥锁? 在所有使用互斥锁的线程全部退出之后,就可以销毁互斥锁
  • 什么时候使用加锁? 线程访问临界资源之前,进行加锁操作
  • 什么时候使用解锁?线程所有可能退出的地方进行解锁
  • 加锁之后不解锁?有一个工作线程加锁,之后未进行解锁,其他工作线程再次去获取锁时,互斥锁中计数器中的值还是0,就要被阻塞等待:所以加锁之后一定要记得解锁,否则就会导致死锁。

互斥量常见问题

加锁了之后,线程在临界区中是否会切换?

   会被切换。虽然被切换了,但是你是持有锁被切换的, 所以其他抢票线程要执行临界区代码,也必须先申请锁,锁它是无法申请成功的,所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性!

   在没有持有锁的线程看来,对我最有意义的情况只有两种:1. 线程1没有持有锁(什么都没做) 2. 线程1释放锁(做完),此时我可以申请锁!


加锁就是串行执行了吗?

   是的,执行临界区代码一定是串行的!


要访问临界资源,每一个线程都必须先申请锁,每一个线程都必须先看到同一把锁并且去访问它,锁本身是不是就是一种共享资源?

   是的


谁来保证锁的安全呢?

  为了保证锁的安全,申请和释放锁,必须是原子的,自己保证安全


互斥锁的原理

  互斥锁的底层是一个互斥量,而互斥量的本质就是一个计数器,计数器的取值只有两种情况,一种是 1 ,一种是 0 ;1表示当前临界资源可以被访问。0表示当前临界资源不可以被访问。

  假设有一临界资源,有一个线程A和一个线程B,按之前的黄牛抢票的思路,只要线程拥有时间片就可以去访问这块临界资源,现在我们给线程 A 和线程 B 都加上互斥锁,假设此时线程A要去访问临界资源,它首先得获取互斥锁,而此时互斥锁中的值为1,表示当前可以访问,线程 A 去访问临界资源然后将互斥锁中的 1 改为 0 ,此时如果线程B如果想要访问临界资源之前先要获取互斥锁,而此时互斥锁中的值为0,所以线程 B 此时不能访问临界资源,等线程 A 访问完毕后,就会将锁释放,此时所中的值就会从 0 变为 1 , 此时线程 B 判断互斥锁中的值变为 1 可以访问了,就可以去访问临界资源了;互斥锁保证了当前临界资源在同一时刻只能被一个执行流访问。
Linux学习笔记——多线程_第13张图片
  若要多个线程访问临界资源的时候是互斥访问的属性,一定要在多个线程中进行同一把锁的加锁操作,这样每个线程在访问临界资源之前都要获取这把锁,若锁中的值为 1 就能访问,为 0 则不能访问;若只给线程 A 加锁线程 B 不加锁,那么线程 A 判断锁中的值为 1 ,则访问临界资源并将锁中的值改为 0 ,而线程 B 为加这把锁,则不需要获取锁并判断锁中的值是否为 1 就可以直接对临界资源进行访问,会出现线程不安全现象。


lock和unlock的伪代码
Linux学习笔记——多线程_第14张图片
  为了实现互斥锁操作,Linux都提供了swap或exchange指令,该指令的作用是把al寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。


死锁

死锁的概念
Linux学习笔记——多线程_第15张图片

  死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。线程A获取到互斥锁1 ,线程B获取到互斥锁2的时候,线程A和线程B同时还想获取对方手里的锁(线程A还想获取互斥锁2,线程B还想获取互斥锁1),此时就会导致死锁。


死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用(一个互斥锁,只能被一个执行流在同一时刻拥有)
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放(线程A拿着 1 锁还想请求 2 锁,线程B拿着 2 锁还想请求 1 锁)
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系(线程A等待线程B拿的锁,线程B等待线程A拿的锁)

避免死锁

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

避免死锁算法

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

线程同步

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


条件变量

  当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量,条件变量可以让不让线程频繁的检测资源是否就绪和等待,当条件就绪的时候,通知对应的线程,让他进行资源的访问和申请。

条件变量函数

条件变量静态初始化

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

条件变量动态初始化

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_signal(pthread_cond_t cond);
作用:通知PCB等待队列当中的线程,线程接收到了,则从PCB等待队列当中出队操作。至少唤醒一个PCB等待队列当中的线程。
int pthread_cond_broadcast(pthread_cond_t cond);
作用:唤醒所有PCB等待队列当中的线程。

条件变量等待

int pthread_cond_wait(pthread_cond_t restrict cond,pthread_mutex_t restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
作用:谁调用该接口,就将谁的放入到PCB等待队列中

线程同步案例

#include 
#include 
#include 
#include 

#define TNUM 4
typedef void (*func_t)(const std::string &name,pthread_mutex_t *pmtx, pthread_cond_t *pcond);
volatile bool quit = false;

// pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

class ThreadData
{
public:
    ThreadData(const std::string &name, func_t func, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
    :name_(name), func_(func), pmtx_(pmtx), pcond_(pcond)
    {}
public:
    std::string name_;
    func_t func_;
    pthread_mutex_t *pmtx_;
    pthread_cond_t *pcond_;
};

void func1(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while(!quit)
    {
        // wait一定要在加锁和解锁之间进行wait!
        // v2: 
        pthread_mutex_lock(pmtx);
        // if(临界资源是否就绪-- 否) pthread_cond_wait
        pthread_cond_wait(pcond, pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即被阻塞
        std::cout << name << " running -- 播放" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}

void func2(const std::string &name,pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while(!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即被阻塞
        if(!quit) std::cout << name << " running  -- 下载" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}
void func3(const std::string &name,pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while(!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即被阻塞
        std::cout << name << " running  -- 刷新" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}
void func4(const std::string &name,pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while(!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即被阻塞
        std::cout << name << " running  -- 扫码用户信息" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}

void *Entry(void *args)
{
    ThreadData *td = (ThreadData*)args; //td在每一个线程自己私有的栈空间中保存
    td->func_(td->name_, td->pmtx_, td->pcond_); // 它是一个函数,调用完成就要返回!
    delete td;
    return nullptr;
}

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++)
    {
        std::string name = "Thread ";
        name += std::to_string(i+1);
        ThreadData *td = new ThreadData(name, funcs[i], &mtx, &cond);
        pthread_create(tids + i, nullptr, Entry, (void*)td);
    }
    // ctrl new thread
    int cnt = 10;
    while(cnt)
    {
        std::cout << "resume thread run code ...." << cnt-- << std::endl;
        pthread_cond_signal(&cond);
        // pthread_cond_broadcast(&cond);
        sleep(1);
    }
    std::cout << "ctrl done" << std::endl;
    quit = true;
    pthread_cond_broadcast(&cond);
    for(int i = 0; i < TNUM; i++)
    {
        pthread_join(tids[i], nullptr);
        std::cout << "thread: " << tids[i] << "quit" << std::endl;
    }
    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);
    return 0;
}

Linux学习笔记——多线程_第16张图片
  由上面结果可以看出,线程同步会使线程会按照一定的顺序进行访问临界资源,完成对应的功能,但是哪个线程先执行是由调取器决定的。


条件变量的等待接口参数为什么需要互斥锁

Linux学习笔记——多线程_第17张图片
  由于需要是在pthread_cond_wait函数内部进行解锁,当有线程进去之后要把锁释放别人才能用,解锁之后,其他的执行流才能获取到这把互斥锁,所以,需要传入互斥锁,否则,如果在调用pthread_cond_wait线程在进行等待的时候,不释放互斥锁,其他线程就不能解锁。


线程等待的时候,被唤醒之后,需要做什么事情

Linux学习笔记——多线程_第18张图片


当被唤醒时从哪里醒来呢?

  哪里阻塞挂起,就从哪里唤醒, 被唤醒的时候,还是在临界区内,pthread_cond_wait,会自动帮助线程获取锁。


生产者消费者模型

生产者消费者模型的概念

  生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

Linux学习笔记——多线程_第19张图片
Linux学习笔记——多线程_第20张图片


生产者消费者模型优点

  • 解耦
  • 支持并发
  • 支持忙闲不均
    Linux学习笔记——多线程_第21张图片

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

  BlockingQueue 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。

Linux学习笔记——多线程_第22张图片


生产者与消费者模型代码实现

Producer-consumer


POSIX信号量

POSIX信号量的概念

  POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。互斥的条件是共享资源被当作整体来使用,也就是访问同一个资源的时候进程进行同步或互斥,当一个共享资源不当作一个整体,而让不同的执行流访问不同的区域,就实现了并发,而信号量就用来描述有多少个共享资源。信号量的本质是一个计数器,描述临界资源有效个数的计数器。 访问临界资源的时候,必须先申请信号量(sem–,预定资源 ,P),使用完毕后信号量资源(sem–,释放资源 ,v)


POSIX信号量函数

初始化信号量

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

Linux学习笔记——多线程_第23张图片

销毁信号量

int sem_destroy(sem_t *sem);
参数:sem为传入待要销毁的信号量即可。

等待信号量

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

Linux学习笔记——多线程_第24张图片

发布信号量

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

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

  上一个生产者-消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序(POSIX信号量)。环形队列采用数组模拟,用模运算来模拟环状特性。环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态。但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程。
Linux学习笔记——多线程_第25张图片
  如果生产和消费线程指向了环形结构的同一位置(为空/为满),生产和消费都要有互斥或者同步问题,但是大概率情况下生产者和消费者线程指向的都是不同的位置。所以当生产和消费指向同一位置的时候,让他们具有互斥关系就可以,而让生产者和消费者线程不指向同一个位置的时候,实现并发执行。

  期望:生产者不能将消费者套圈,且消费者一定不能超过生产者。当环形结构为空的时候,一定要让生产者先运行,环形结构为满的时候,一定要让消费者先运行。

  生产者最关注的是空间资源,而消费者关注的是数据资源。

代码实现:
基于环形队列的生产消费模型代码实现


线程池

线程池概念

  什么是线程池?简单点说,线程池就是有一堆已经创建好了的线程,初始它们都处于空闲等待状态,当有新的任务需要处理的时候,就从这个池子里面取一个空闲等待的线程来处理该任务,当处理完成了就再次把该线程放回池中,以供后面的任务使用。当池子里的线程全都处理忙碌状态时,线程池中没有可用的空闲等待线程,此时,根据需要选择创建一个新的线程并置入池中,或者通知任务线程池忙,稍后再试。

线程池作用

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

线程池应用场景

  • 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
  • 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  • 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。

线程安全的单例模式

单例模式概念

  单例模式是一种创建型模式,它会限制应用程序,使其只能创建某一特定类>类型的一个单一的实例。举例来说,一个web站点将会需要一个数据库连接>对象,但是应该有且只能有一个,因此我们通过使用单例模式来实现这种限>制。我们可以使用一个静态属性来保证对于一个特定的类来说只存在一个单一的>实例。

单例模式特点

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

饿汉方式实现单例模式

  • 吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭。
template <typename T>
class Singleton 
{
private:
	static T data;      //定义静态的类对象,程序加载类就加载对象
public:
	static T* GetInstance() 
	{
		return &data;
	}
};

懒汉方式实现单例模式

  • 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式。
class Singleton 
{
	static T* inst;  //定义静态的类对象指针,程序运行时才加载对象
public:
	static T* GetInstance() 
	{
		if (inst == NULL) 
		{
			inst = new T();
		} 
		return inst;
	}
};

STL智能指针和线程安全

STL中的容器是否是线程安全的?

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

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


其他常见的各种锁

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

你可能感兴趣的:(linux,线程,多线程)