【Linux】线程概念 | 互斥

1.线程的概念

在之前的linux学习中已经接触过了进程的概念进程由一个task_struct结构体在操作系统中进行描述CPU在执行的时候会依照进程时间片进行轮询调度让每一个进程的代码都得以推进实现多个进程的同时运行

而线程可以理解为是一种轻量化的进程每一个进程都可以创建多个线程并行执行不同的代码

进程:线程 = 1:N

在之前的多进程操作中我们使用fork接口创建子进程通过if/else语句判断实现对特定执行流的划分

  • 创建子进程时需要拷贝一份task_struct/mm_struct并创建页表
  • 当子进程修改了一部分变量会发生写时拷贝修改页表在物理内存上的映射

可以看到当我们需要创建一个新进程的时候操作系统需要做不少的工作

image-20221215191721355

1.1 执行流

让我们康康执行流这一概念

  • 单执行流进程内部只有一个执行流的进程
  • 多执行流进程内部有多个执行流的进程

进程=内核数据结构+代码和数据在内核视角中进程是承担分配系统资源的基本实体进程的基座属性

  • 进程向系统申请资源的基本单位系统分配
  • 线程系统调度的基本单位

1.2 线程创建时做了什么

那线程的创建需要做什么呢

不同操作系统的实现不同一般用tcb指代描述线程的结构体

在linux中没有进程和线程在概念上的区分其以执行流为基础线程只是简单的对task_strcut进行了二次封装线程是在进程内部运行的执行流

  • 说人话linux下的线程是用进程模拟
  • 换句话linux下的进程也是一种线程但是其只有一个执行流
  • 对于CPU而言其看到的task_struct都是一个执行流

而创建线程时也有说法线程隶属于某一个进程下并不是独立的子进程所以不需要创建新的mm_struct和页表映射创建的效率高于子进程。只需要将task_struct指向原有进程的mm_struct和页表即可。

image-20221215192345757

同样的CPU在推行多线程操作的时候无须执行pcb切换就能实现单进程多个线程操作的同时进行执行效率变高

线程是一种Light weight process 轻量级进程简称LWP

1.3 内核源码中的体现

task_strcut结构体中有这么一个字段

/* CPU-specific state of this task */
	struct thread_struct thread;

转到定义其内部都是一些寄存器信息用于标识这个线程的基本信息。这也是linux中没有单独实现线程tcb的体现而是用task_struct来模拟的

struct thread_struct {
	/* Cached TLS descriptors: */
	struct desc_struct	tls_array[GDT_ENTRY_TLS_ENTRIES];
	unsigned long		sp0;
	unsigned long		sp;
#ifdef CONFIG_X86_32
	unsigned long		sysenter_cs;
#else
	unsigned long		usersp;	/* Copy from PDA */
	unsigned short		es;
	unsigned short		ds;
	unsigned short		fsindex;
	unsigned short		gsindex;
#endif
#ifdef CONFIG_X86_32
	unsigned long		ip;
#endif
#ifdef CONFIG_X86_64
	unsigned long		fs;
#endif
	unsigned long		gs;
	/* Hardware debugging registers: */
	unsigned long		debugreg0;
	unsigned long		debugreg1;
	unsigned long		debugreg2;
	unsigned long		debugreg3;
	unsigned long		debugreg6;
	unsigned long		debugreg7;
	/* Fault info: */
	unsigned long		cr2;
	unsigned long		trap_no;
	unsigned long		error_code;
	/* floating point and extended processor state */
	union thread_xstate	*xstate;
#ifdef CONFIG_X86_32
	/* Virtual 86 mode info */
	struct vm86_struct __user *vm86_info;
	unsigned long		screen_bitmap;
	unsigned long		v86flags;
	unsigned long		v86mask;
	unsigned long		saved_sp0;
	unsigned int		saved_fs;
	unsigned int		saved_gs;
#endif
	/* IO permissions: */
	unsigned long		*io_bitmap_ptr;
	unsigned long		iopl;
	/* Max allowed port in the bitmap, in bytes: */
	unsigned		io_bitmap_max;
/* MSR_IA32_DEBUGCTLMSR value to switch in if TIF_DEBUGCTLMSR is set.  */
	unsigned long	debugctlmsr;
	/* Debug Store context; see asm/ds.h */
	struct ds_context	*ds_ctx;
};

1.4 线程的私有物

我们知道一个进程是完全独立的。但是线程并不是因为线程只是进程的一个执行流分支它从进程继承了绝大部分属性也可以理解为是共享的

  • 用户id和组id
  • 进程id
  • 进程工作目录
  • 文件描述符表
  • 信号的处理方式如果进程有对某个信号进行自定义捕捉那么线程会共用这个自定义捕捉
  • 和进程共用一个堆

但线程也会有自己的私有物

  • 线程id
  • 线程独立的寄存器因为线程也需要执行代码有上下文数据
  • 栈线程运行函数时也需要压栈和出栈必须独立否则执行流会出问题
  • errno单独的报错信息
  • 信号屏蔽字可以单独针对某个信号处理
  • 线程调度优先级

1.5 线程优缺点

1.5.1 缺点

  • 线程是缺乏保护的不具备进程的独立性这也被称为健壮性线程的健壮性低

    • 当进程被停止的时候其下线程也会被停止
    • 当有一个线程出bug了会让整个进程退出
    • 多线程中的全局变量问题
  • 线程缺乏访问控制在一个线程中调用某些操作系统的接口会影响整个进程

  • debug多线程较麻烦

  • 如果同一个进程所用线程太多可能会无法充分利用cpu性能而造成性能损失

1.5.2 优点

  • 开辟的消耗低于进程占用的资源低于进程
  • 切换线程无须切换页表等结构速度快
  • 等待慢IO设备时进程可以继续执行其他操作将部分IO操作重叠能让进程同时等待多个IO操作
  • 能充分利用处理器的可并行数量

2.基础函数

linux下提供了pthread库来实现线程操作

2.1 pthread_create

人如其名这个函数的作用是来创建新进程的

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
					void *(*start_routine) (void *), void *arg);
//Compile and link with -pthread.
  • 第一个参数是一个输出型参数为该线程的id
  • 第二个参数是用于指定线程的属性暂时设置为NULL使用默认属性
  • 第三个参数是让该进程执行的函数这是一个函数指针参数和返回值都为void*
  • 第四个参数是传给第三个执行函数的参数

创建正常后返回0否则返回错误码

注意使用了pthread库后需要在编译的时候指定链接-lpthread

typedef unsigned long int pthread_t;//线程id

创建线程后打印可以发现线程id是一个非常大的值并不像进程PID那么小

//cout << "pthread_create "<< t1 << " " << t2 << endl;
pthread_create 140689524995840 140689516603136

可以通过printf %x的方式来减少打印长度

//printf("0x%x  0x%x\n",t1,t2);
0x393d0700  0x38bcf700

2.2 pthread_join

光是创建进程还不够我们还需要对进程进行等待

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
//Compile and link with -pthread.

这里第一个参数是线程的id第二个参数是进程的退出状态

等待成功后返回0否则返回错误码

  • join可以在线程退出后释放线程的资源
  • 同时获取线程对应的退出码
  • join还能保证是新创建的线程退出后主线程才退出

2.2.1 基础的多线程操作

有了这两个我们就能写一个简单的多线程操作了

#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<sys/types.h>
using namespace std;

void* func1(void* arg)
{
    while(1)
    {
        cout << "func1 thread:: " << (char*)arg << " :: " << getpid() << endl;
        sleep(1);
    }
}

void* func2(void* arg)
{
    while(1)
    {
        cout << "func2 thread:: " << (char*)arg << " :: " << getpid() << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func1,(void*)"1");
    pthread_create(&t2,nullptr,func2,(void*)"2");

    while(1)
    {
        cout << "this is main::" << getpid()<<endl;
        sleep(1);
    }

    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);

    return 0;
}

执行会发现多线程操作成功启动且打印的进程pid都是一样的代表其隶属于同一个进程

image-20221215203210372

我们可以用下面的语句来查看轻量级进程

ps -aL

可以看到执行了程序之后出现了3个PID相同LWP不同的轻量级进程这就代表我们的多线程操作成功了

同时也能看到在多线程操作时谁先运行是不确定的。这是由系统调度随机决定的

image-20221215203326193

2.2.2 C++的多线程操作

C++11也支持了多线程操作其封装了操作系统的pthread接口基本的操作很相似

void test2()
{
    thread t1(func1,(char*)"test1");
    thread t2(func2,(char*)"test2");
   
    while(1)
    {
        cout << "this is main:: " << getpid()<<endl;
        sleep(1);
    }

    t1.join();
    t2.join();
}

执行后的效果是一样的C++的thread库还可以传入functional封装的可调用函数和lambda表达式

image-20221215205453606

2.3 线程退出

2.3.1 retval

int pthread_join(pthread_t thread, void **retval);

我们可以使用该函数的第二个参数来获取线程所执行方法的返回值。retval是一个二级指针是一个输出型参数

#include<iostream>
#include<pthread.h>
#include<thread>
#include<unistd.h>
#include<sys/types.h>
using namespace std;
void* func1(void* arg)
{
    int a = 5;
    while(a--)
    {
        cout << "func1 thread:: " << (char*)arg << " :: " << getpid() << endl;
        sleep(1);
    }
    cout << "func1 exit" << endl;
    return (void*)100;
}

void* func2(void* arg)
{
    int a = 10;
    while(a--)
    {
        cout << "func2 thread:: " << (char*)arg << " :: " << getpid() << endl;
        sleep(1);
    }
    cout << "func2 exit" << endl;
    return (void*)10;
}

void test3()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func1,(void*)"1");
    pthread_create(&t2,nullptr,func2,(void*)"2");

    int a = 15;
    while(a--)
    {
        cout << "this is main:: " << getpid()<<endl;
        sleep(1);
    }

    void* r1;
    void* r2;
    pthread_join(t1,&r1);
    pthread_join(t2,&r2);

    sleep(2);
    cout << "retval 1 : " << (long long)r1 << endl;
    cout << "retval 2 : " << (long long)r2 << endl;
}

int main()
{    
    test3();
    return 0;
}

可以看到当两个线程退出之后主函数中成功打印出了他们的返回值

image-20221216184220924

注意因为我们是将void*的指针强转为int如果在打印的时候强转为int会出现精度丢失的报错需要使用long long来规避报错

[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ make
g++ test.cpp -o test -lpthread -std=c++11
.test.cpp: In function ‘void test3()’:
test.cpp:88:35: error: cast from ‘void*’ to ‘int’ loses precision [-fpermissive]
     cout << "retval 1 : " << (int)r1 << endl;
                                   ^
make: *** [test] Error 1

2.3.2 pthread_exit

除了直接return线程还可以调用pthread_exit函数实现退出

#include <pthread.h>
void pthread_exit(void *retval);
//Compile and link with -pthread.

效果完全一样

    //return (void*)10;
    pthread_exit((void*)10);

注意主线程main中调用该函数并不会导致进程退出

void* func2(void* arg)
{
    int a = 10;
    while(a--)
    {
        cout << "func2 thread:: " << (char*)arg << " :: " << getpid() << " tid: " << syscall(SYS_gettid) << endl;
        sleep(1);
    }
    cout << "func2 exit" << endl;
    pthread_exit((void*)10);
}

void test5()
{
    pthread_t t1,t2;
	//func2会执行10s
    pthread_create(&t1,nullptr,func2,(void*)"1");
    pthread_create(&t2,nullptr,func2,(void*)"2");

    sleep(1);

    pthread_detach(t1);
    pthread_detach(t2);

    sleep(1);
}

int main()
{    
    test5();
    pthread_exit(0);//主线程提前退出
    cout << "main exit" << endl;

    return 0;
}

可以看到主函数已经调用了pthread_exit退出了但是线程还在跑

[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test
func2 thread:: 1 :: 9474 tid: 9475
func2 thread:: 2 :: 9474 tid: 9476
func2 thread:: 1 :: 9474 tid: 9475
func2 thread:: 2 :: 9474 tid: 9476
main exit
func2 thread:: 1 :: 9474 tid: 9475
func2 thread:: 2 :: 9474 tid: 9476

2.3.3 ptrhead_cancel

除了上面俩种方式我们还可以在main里面直接把某一个线程给关掉

#include <pthread.h>
int pthread_cancel(pthread_t thread);
//Compile and link with -pthread.
void test3()
{
    pthread_t t1,t2;
    pthread_create(&t1,nullptr,func1,(void*)"1");
    pthread_create(&t2,nullptr,func2,(void*)"2");

    int a = 15;
    while(a--)
    {
        cout << "this is main:: " << getpid()<<endl;
        sleep(1);
        if(a==11)
        {
            pthread_cancel(t1);
            pthread_cancel(t2);
            break;
        }
    }
    void* r1;
    void* r2;
    pthread_join(t1,&r1);
    pthread_join(t2,&r2);

    sleep(2);
    cout << "retval 1 : " << (long long)r1 << endl;
    cout << "retval 2 : " << (long long)r2 << endl;
}

被提前终止的进程返回值都为-1

image-20221216190338205

2.3.4 为什么进程退出不会向主进程发送信号

要理清楚这个问题还是需要深知一个概念线程是进程中的一个执行流它并不是一个独立的进程。

先来回顾一下进程退出的几种情况

  • 代码跑完结果正确
  • 代码跑完结果有问题
  • 代码出错了异常

线程退出的情况也是这样但线程如果因为某些异常退出进程也会同步退出

[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test
this is main:: 13845
Floating point exception
[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ 

由此可见线程异常 = 进程异常

这里也就涉及到1.5.1中提到的线程健壮性问题线程的异常会影响其他线程的运行会导致进程整体异常退出。

所以在join等待线程退出的时候我们只需要考虑线程正常退出的情况

异常退出的时候恐怕也等不了😂因为进程也挂了

2.3.5 exit

任何一个线程执行exit()函数都会导致整个进程退出


2.4 pthread_detach

等待是有性能损失的默认创建的进程是joinable也就是可以被主线程进行pthread_join等待的

这个函数的作用是让主线程不管创建出来的子线程也不用去等待它相当于取消了它的joinable属性

就好比父进程不想管子进程的时候将SIGCHLD设置为SIG_IGN

#include <pthread.h>
int pthread_detach(pthread_t thread);
//Compile and link with -pthread.

一个线程是否应该等待取决于是否需要获取该线程的返回值如果无须获取返回值则使用分离能提高运行效率

2.4.1 实操

使用也很简单只需要指定线程的id就行了

void test4()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func3,(void*)"1");
    pthread_create(&t2,nullptr,func3,(void*)"2");

    while(1)
    {
        cout << "this is main - global: " << global << " - &global: " << &global << endl;
        sleep(1);
    }

    pthread_detach(t1);
    pthread_detach(t2);
}

运行上也不会有什么区别但是我们已无法获取到该线程的返回值

image-20221218112720052


2.4.2 detach后join

但如果我们在detach之后又进行pthread_join会发生什么呢

void* func3(void* arg)
{
    pthread_detach(pthread_self());
    int a = 7;
    while(a--)
    {
        printf("func thread:%s - global:%d - &global:%p\n",(char*)arg,global,&global);
        global++;
        sleep(1);
    }
    cout << "func exit" << endl;
    return (void*)10;
}

void test4()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func3,(void*)"1");
    pthread_create(&t2,nullptr,func3,(void*)"2");

    void* r1=nullptr;
    void* r2=nullptr;
    pthread_join(t1,&r1);
    pthread_join(t2,&r2);
    sleep(2);
    cout << "retval 1 : " << (long long)r1 << endl;
    cout << "retval 2 : " << (long long)r2 << endl;
}

诶这不还是获取到了返回值吗这么说他这个detach岂不是没用

[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test
func thread:1 - global:103 - &global:0x7fb5648b06fc
func thread:2 - global:103 - &global:0x7fb5640af6fc
func thread:1 - global:104 - &global:0x7fb5648b06fc
func thread:2 - global:104 - &global:0x7fb5640af6fc
func exit
func exit
retval 1 : 10
retval 2 : 10
[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ 

实际上当我们create一个线程的时候它会先去执行线程创建的相关代码此时main又直接去执行后面的代码了此时pthread_join的调用是成功的因为线程自己的detach代码还没有被执行


而如果我们在create之后等线程开始运行了在执行detach此时join就会失败

void test4()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func3,(void*)"1");
    pthread_create(&t2,nullptr,func3,(void*)"2");

    sleep(2);

    pthread_detach(t1);
    pthread_detach(t2);

    sleep(1);

    void* r1=nullptr;
    void* r2=nullptr;
    int ret = pthread_join(t1,&r1);
    cout << ret << ":" << strerror(ret) << endl;
    ret = pthread_join(t2,&r2);
    cout << ret << ":" << strerror(ret) << endl;

    cout << "retval 1 : " << (long long)r1 << endl;
    cout << "retval 2 : " << (long long)r2 << endl;

    sleep(20);
}

打印错误码也能看到系统提示我们给join传入了一个无效的参数线程依旧在正常运行

[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test
func thread:1 - global:101 - &global:0x7f2d439136fc
func thread:2 - global:101 - &global:0x7f2d431126fc
func thread:2 - global:102 - &global:0x7f2d431126fc
func thread:1 - global:102 - &global:0x7f2d439136fc
22:Invalid argument
22:Invalid argument
retval 1 : 0
retval 2 : 0
func thread:2 - global:103 - &global:0x7f2d431126fc
func thread:1 - global:103 - &global:0x7f2d439136fc

所以正确的做法应该是在主线程中分离线程不要在线程自己的代码中执行detach否则就会出现上面的分离失败的情况

2.4.3 线程分离后主线程先退出

如果执行完毕pthread_detach后主线程提前退出了会发生什么

void test5()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func3,(void*)"1");
    pthread_create(&t2,nullptr,func3,(void*)"2");

    sleep(1);

    pthread_detach(t1);
    pthread_detach(t2);

    sleep(2);
    cout << "main exit" << endl;
}

显而易见线程也跟着一并退出了

[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test
func thread:1 - global:100 - &global:0x7f01cd49a6fc
func thread:2 - global:100 - &global:0x7f01ccc996fc
func thread:2 - global:101 - &global:0x7f01ccc996fc
func thread:1 - global:101 - &global:0x7f01cd49a6fc
func thread:2 - global:102 - &global:0x7f01ccc996fc
func thread:1 - global:102 - &global:0x7f01cd49a6fc
main exit
[muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ 

因为线程没有独立性完全属于这个进程。不可能出现你家房子塌了你自己的房间还在的情况😂

进程退出的时候操作系统就回收了这个进程的程序地址空间连资源都被释放了线程就没有办法继续运行自然就退出了。

所以为了避免这种问题一般我们分离线程的时候都倾向于让主线程保持在后台运行常驻内存的程序

2.5 gettid/syscall

该函数是一个系统接口但它并不能直接运行

NAME
       gettid - get thread identification
SYNOPSIS
       #include <sys/types.h>
       pid_t gettid(void);

       Note:  There  is  no  glibc wrapper for this system call; see
       NOTES.

我们需要用syscall函数来调用该接口这也是第一次接触到syscall函数

#define _GNU_SOURCE         /* See feature_test_macros(7) */
#include <unistd.h>
#include <sys/syscall.h>   /* For SYS_xxx definitions */
int syscall(int number, ...);

在syscall的man手册中我们就能看到获取线程id相关的示例

//EXAMPLE
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>

int main(int argc, char *argv[])
{
    pid_t tid;

    tid = syscall(SYS_gettid);
    tid = syscall(SYS_tgkill, getpid(), tid);
}

用下面的代码进行测试

void* func2(void* arg)
{
    int a = 10;
    while(a--)
    {
        cout << "func2 thread:: " << (char*)arg << " :: " << getpid() << " tid: " << syscall(SYS_gettid) << endl;
        sleep(1);
    }
    cout << "func2 exit" << endl;
    pthread_exit((void*)10);
}

void test1()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func2,(void*)"1");
    pthread_create(&t2,nullptr,func2,(void*)"2");
   
    while(1)
    {
        printf("tis is main - pid:%d - tid:%d\n",getpid(),syscall(SYS_gettid));
        sleep(1);
    }

    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
}

运行可以看到进程打印出了相同的PID和不同的TID其TID对应的就是ps -aL中显示的LWP编号

image-20221218130755643

3.相关概念

3.1 线程id是什么

前面提到过pthread_t是线程独立的id本质上是一个无符号长整形打印出来后是一个很大的数字。这个数字有什么特别的含义吗

先来回顾一下线程的基本概念

  • 线程是一个独立的执行流
  • 线程在运行过程中会产生自己的临时数据
  • 线程调用函数的压栈出栈操作有自己独立的栈结构

因此既然有一个独立的栈结构其就需要有一个标识符来指向这个栈结构方便程序运行的时候进行调用

所以pthread_t本质上是一个地址其指向的就是这个线程的控制块其内部包含了这个线程的独立栈结构。

//printf("0x%x  0x%x\n",t1,t2);
0x393d0700  0x38bcf700 //打印出来的结果也很像地址

3.2 pthread库

pthread库并不是一个内核级的接口库其实际上是封装了系统的clone/vfork等接口从而为我们提供的用户级的线程库。

使用pthread库创建的进程和内核中的LWP是1:1

image-20221218102338117

pthread是一个动态库所以在编译的时候需要加上链接选项

g++ test.cpp -o test -lpthread

在我的 动静态库 的博客中有讲述过动态库是在运行的时候动态链接的其会将库中的代码映射到进程地址空间的共享区从而调用动态库中的代码

举个例子当我们调用pthead_create的时候进程会跳到共享区中执行动态库中的代码创建成功后返回自己的代码区完成一个线程的创建

而线程所用的独立栈也是pthread库帮我们管理的。因为有共享区的存在我们能通过pthread_t直接访问到动态库中管理的线程的控制模块从而完成线程的压栈、出栈等等操作

image-20221218103643205

下为linux的pthreadtypes.h中的部分内容

# define __SIZEOF_PTHREAD_ATTR_T 36
typedef unsigned long int pthread_t;

union pthread_attr_t
{
  char __size[__SIZEOF_PTHREAD_ATTR_T];
  long int __align;
};
#ifndef __have_pthread_attr_t
typedef union pthread_attr_t pthread_attr_t;
# define __have_pthread_attr_t	1
#endif

3.3 线程的局部存储

假设我们有一个全局变量我们想让创建出来的每一个线程都能独立的使用这个全局变量那就需要用到线程的局部存储

int global = 10;//全局变量
void* func3(void* arg)
{
    int a = 10;
    while(a--)
    {
        cout << "func thread " << (char*)arg <<  " - global: " << global << " - &global: " << &global << endl;
        sleep(1);
    }
    cout << "func exit" << endl;
}

void test4()
{
    pthread_t t1,t2;

    pthread_create(&t1,nullptr,func3,(void*)"1");
    pthread_create(&t2,nullptr,func3,(void*)"2");

    while(1)
    {
        cout << "this is main - global: " << global << " - &global: " << &global << endl;
        sleep(1);
    }

    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
}

执行不管是主线程还是线程都打印的是相同的值和地址

image-20221218110718405

如果在执行的函数func3中添加一个global++则能观察到所有线程都是公用的一个变量这里的+是同步的。

image-20221218111031984

如果我们想让int global变成局部变量则需要在它之前加上一个__thread

__thread int global = 100;//可以让线程独立使用的全局变量

此时可以看到两个线程和主线程打印的global变量地址不同他们的++操作是独立的变量的值也是独立的

image-20221218111639283

这就实现了将某一个变量划分给线程进行局部存储

4.线程互斥问题

4.1 临界资源

在先前共享内存 信号量的博客中已经涉及到了这部分的内容即关于操作原子性和访问临界资源/临界区的相关问题。

  • 能被多个进程/线程看到的资源被称为临界资源
  • 进程/线程访问临界资源的代码被称为临界区

在线程中同样存在访问临界资源而导致的冲突

  • 线程A对一个全局变量val进行了-1操作当操作执行到放回内存那一步的时候发生了线程切换线程B开始工作
  • 线程B同样访问了该全局变量val对它进行了-10操作此时因为线程A的-1操作尚未写回内存全局变量val还是保持初值。线程b将-10之后的全局变量val写回了内存
  • 又发生了线程切换跳转到线程A停止的线程上下文数据中开始执行将全局变量写入内存
  • 这时候线程B的-10操作就被A的写入覆盖了

举个实际点的例子以100为全局变量的初始值

  • 线程A执行-1100-1=99还未写入内存时就线程切换
  • 线程B取到的全局变量还是100对其执行-10并写入内存 此时全局变量为90
  • 返回线程A继续执行写入内存操作全局变量又被复写成了99相当于B的操作是无效的

这种条件下会产生很多问题也是我们不希望看到的

4.2 原子/互斥性

这种时候我们就需要保证访问该全局变量的操作是原子的不能出现中间状态

也应该是互斥的不能出现两个线程同时访问一份资源的情况

互斥性任何时候都只有一个执行流在访问某一份资源

image-20221218193343035

为了达成这一目的我们需要给线程的操作加锁

4.3 线程加锁

线程加锁涉及到几个操作

  • 提供一把锁
  • 在需要维持原子性临界区的位置加上锁
  • 访问临界区结束后打开锁
  • 进程结束后把锁丢了

接下来就让我们一一解决这些问题

4.3.1 pthread_mutex_init

pthread在设计之初就考虑到了这种问题所以它便给我们提供了加锁相关的操作

#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                       const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

首先我们需要定义一把锁类型是pthread_mutex_t

  • 如果我们需要的是一把全局变量的锁则可以直接使用PTHREAD_MUTEX_INITIALIZER给这把锁初始化
  • 如果是一把局部的锁则使用函数pthread_mutex_init进行初始化

初始化的方法很简单传入锁和对应的属性就行。此时我们忽略属性问题设置为NULL使用默认属性

//使用默认属性的全局锁or静态static锁
//无须调用函数初始化可以直接用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

//使用函数进行初始化局部的锁当然也可以初始化全局锁
pthread_mutex_t mutex;//定义一把锁
pthread_mutex_init(&mutex, nullptr);//初始化
pthread_mutex_destroy(&mutex);//销毁

4.3.2 加锁/解锁

有了锁那么就可以在需要的位置加上这把锁

#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

其中lock是阻塞式加锁如果你调用这个接口的时候锁正在被别人使用则会在这里等待trylock是非阻塞加锁如果你调用该接口时锁正被使用则直接return返回

 The pthread_mutex_trylock() function shall be equivalent to pthread_mutex_lock(), except that if the mutex object referenced  by  mutex  is  currently locked (by any thread, including the current thread), the call shall return immediately. 

加了锁之后在需要的位置unlock解锁

  • 加锁和解锁操作本身是原子的不会出现冲突
  • 加了锁之后可以理解为加锁解锁操作中间的代码也是原子性的必须要运行到解锁位置才能让另外一个线程/进程执行这里的代码
  • 加锁的本质是让线程执行临界区的代码串行化

4.3.3 加锁的注意事项

  • 只对临界区加锁锁保护的就是临界区
  • 加锁的粒度越细越好即加锁的区域越小越好
  • 加锁是编程的一种规范在实际问题中我们要保证访问某一临界资源的所有操作都要加上锁。不能出现函数A加锁了但是B没有加锁的情况这样会导致A的加锁也没有意义

4.4 示例-倒水问题

image-20221224095101707

倒水为示例假设杯子容量为10000装满了水就会溢出。我们使用多个线程对这个杯子加水直到满了之后线程退出

#include<iostream>
#include<string.h>
#include<signal.h>
#include<pthread.h>
#include<thread>
#include<unistd.h>
#include<sys/types.h>
#include<sys/syscall.h>
using namespace std;
//临界资源
int water = 0;//全局变量
int cup = 10000;//杯子的容量

void* func(void* arg)
{
    while(1)
    {
        if(water<cup)//临界区
        {
            cout << (char*)arg << " 水没有满" << water << "\n";
            water++;
        }
        else
        {
            cout << (char*)arg << " 水已经满了 " << water << "\n";
            break;
        }
    }
    cout << (char*)arg << " 线程退出" << "\n";
    return (void*)0;
}

int main()
{
    pthread_t t1,t2,t3,t4;//创建4个线程
    pthread_create(&t1,nullptr,func,(void*)"t1");
    pthread_create(&t2,nullptr,func,(void*)"t2");
    pthread_create(&t3,nullptr,func,(void*)"t3");
    pthread_create(&t4,nullptr,func,(void*)"t4");

    //直接分离线程
    pthread_detach(t1);
    pthread_detach(t2);
    pthread_detach(t3);
    pthread_detach(t4);

    while(1)
    {
        ;//啥都不干
    }

    return 0;
}

输出的结果如下明明水已经满了但还是会有部分线程报告水还没有满且数字有很严重的偏差

t3 水没有满9993
t3 水没有满9994
t3 水没有满9995
t3 水没有满9996
t3 水没有满9997
t3 水没有满9998
t3 水没有满9999
t3 水已经满了
t3 线程退出
 水没有满2723
t4 水已经满了
t4 线程退出
0
t2 水已经满了
t2 线程退出
t1 水没有满9668
t1 水已经满了
t1 线程退出

多运行几次也能发现相同的问题

t2 水没有满9997
t2 水没有满9998
t2 水没有满9999
t2 水已经满了 10000
t2 线程退出
t4 水没有满1889
t4 水已经满了 10001
t4 线程退出
t3 水没有满0
t3 水已经满了 10002
t3 线程退出
t1 水没有满0
t1 水已经满了 10003
t1 线程退出

4.4.1 只有一个线程在工作

除了偏差外还有一个小问题往前翻打印记录会发现一直都是某一个线程在倒水其他线程似乎啥事没有干

t3 水没有满9786
t3 水没有满9787
t3 水没有满9788
t3 水没有满9789
t3 水没有满9790

这是因为当运行t3的时候t3在while循环中继续运行的消耗小于切换到其他线程的消耗。所以控制块就让t3一直运行直到它break退出循环

此时我们只需要加上一个usleep增加每一个while循环中需要处理的负担就能让所有线程都来倒水

//usleep功能把进程挂起一段时间 单位是微秒百万分之一秒
#include <unistd.h>
int usleep(useconds_t usec);

这是因为线程切换同样也是时间片到了从内核返回用户态的时候做检测切换至其他线程。

添加usleep能创造更多内核/用户的中间态从而增多切换线程的次数

void* func(void* arg)
{
    while(1)
    {
        if(water<cup)
        {
            usleep(100);//休息100微秒
            cout << (char*)arg << " 水没有满" << water << "\n";
            water++;
        }
        else
        {
            cout << (char*)arg << " 水已经满了" << "\n";
            break;
        }
    }
    cout << (char*)arg << " 线程退出" << "\n";
    return (void*)0;
}

但是这还是没有解决数字出错的问题

t4 水没有满9995
t3 水没有满9996
t1 水没有满9997
t2 水没有满9998
t4 水没有满9999
t4 水已经满了 10000
t4 线程退出
t3 水没有满10000
t3 水已经满了 10001
t3 线程退出
t1 水没有满10001
t1 水已经满了 10002
t1 线程退出
t2 水没有满10002
t2 水已经满了 10003
t2 线程退出

4.4.2 加锁-问题解决

这时候就需要请出我们的锁了

//省略头文件
int water = 0;//全局变量
int cup = 10000;//杯子的容量
pthread_mutex_t mutex;

void* func(void* arg)
{
    while(1)
    {
        pthread_mutex_lock(&mutex);
        if(water<cup)
        {
            usleep(100);
            cout << (char*)arg << " 水没有满" << water << "\n";
            water++;
            pthread_mutex_unlock(&mutex);

            usleep(100);//假装喝水
        }
        else
        {
            cout << (char*)arg << " 水已经满了 " << water << "\n";
            pthread_mutex_unlock(&mutex);
            //此处也需要加锁否则break出去之后其他线程会因为没有解锁而挂起
            break;
        }
    }
    cout << (char*)arg << " 线程退出" << "\n";
    return (void*)0;
}

// 如果遇到2号信号就在销毁锁后退出进程
void des(int signo)
{
    //销毁锁
    pthread_mutex_destroy(&mutex);
    cout << "pthread_mutex_destroy, exit" << endl;
    exit(0);
}

int main()
{
    signal(SIGINT,des);//自定义捕捉2号信号

    pthread_mutex_init(&mutex,nullptr);//初始化锁

    pthread_t t1,t2,t3,t4;//创建4个线程
    pthread_create(&t1,nullptr,func,(void*)"t1");
    pthread_create(&t2,nullptr,func,(void*)"t2");
    pthread_create(&t3,nullptr,func,(void*)"t3");
    pthread_create(&t4,nullptr,func,(void*)"t4");

    //直接分离线程
    pthread_detach(t1);
    pthread_detach(t2);
    pthread_detach(t3);
    pthread_detach(t4);

    while(1)
    {
        ;//啥都不干
    }

    return 0;
}

运行可见数字错误问题就没有出现了但又出现了只有一个线程工作的问题

t1 水没有满9996
t1 水没有满9997
t1 水没有满9998
t1 水没有满9999
t1 水已经满了 10000
t1 线程退出
t3 水已经满了 10000
t3 线程退出
t4 水已经满了 10000
t4 线程退出
t2 水已经满了 10000
t2 线程退出
^Cpthread_mutex_destroy, exit

这还是因为线程切换的效率问题也有可能是因为其它线程申请锁的时候发现t1在用就进行了阻塞等待而挂起

image-20221219102217522

只需要在解锁之后添加一个usleep模拟其他工作就能让所有线程都跑起来

pthread_mutex_lock(&mutex);
if(water<cup)
{
    usleep(100);
    cout << (char*)arg << " 水没有满" << water << "\n";
    water++;
    pthread_mutex_unlock(&mutex);

    usleep(100);//假装喝水
}

没有出现数据错误加锁的目的成功达到

t1 水没有满9993
t3 水没有满9994
t4 水没有满9995
t2 水没有满9996
t1 水没有满9997
t3 水没有满9998
t4 水没有满9999
t2 水已经满了 10000
t2 线程退出
t1 水已经满了 10000
t1 线程退出
t3 水已经满了 10000
t3 线程退出
t4 水已经满了 10000
t4 线程退出
^Cpthread_mutex_destroy, exit

4.5 加锁的进一步解释

在这个代码示例中我们给中间的几行代码加了锁但这并不意味着执行中间这部分代码的时候就不会发生线程切换

pthread_mutex_lock(&mutex);//加锁
if(water<cup)
{
    cout << (char*)arg << " 水没有满" << water << "\n";
    water++;
}
pthread_mutex_unlock(&mutex);//解锁

事实上代码执行的任何地方都可能发生进程/线程的切换。但因为我们加了锁切换的时候其他线程要来访问这里的资源就必须先申请锁

此时锁在被切走的进线程手上所以其他线程无法访问临界区的资源也就不会发生数据不一致的问题。

QQ图片20220504102516

换言之只要张三拿到了锁那么它也就不担心自己的工作会被别人覆盖的问题

而对其他线程而言张三访问临界区的工作只有还没进入临界区和访问完毕临界区两种状态

因此会导致一个问题那就是线程切换的效率较低其他线程出现了阻塞等待的情况为了避免此问题我们应该让访问临界区的操作快去快回尽量不要在临界区里面干啥耗时的事情

4.5.1 加锁原子性的保证

备注这部分仅供学习参考若有错误还请指出

那么加锁这个操作是如何保证其自身的原子性呢在加锁的途中不会发生线程切换吗

Snipaste_2022-12-24_09-38-46

我找到了一张能大概说明汇编加锁过程的图片其中movb的操作就是将al寄存器写为0xchgb的操作是将al寄存器的内容和内存中mutex锁的值进行交换

  • 开始的时候锁被正常初始化内存中mutex的值为1锁只会被初始化一次
  • 线程A开始加锁al寄存器和mutex的值发生交换此时内存中的mutex为0al为1
  • 判断al不为0代表获取锁成功线程A加锁成功
  • 线程B也来申请锁了movb将al寄存器写为0再和内存中的mutex交换后发现还是0则代表锁在别人手上此时就需要挂起等待

前面一直强调线程是有自己独立的栈结构和上下文数据的在加锁的这部分汇编操作中同样可能会在任何地方发生线程切换。切换的时候线程的上下文数据图中寄存器的状态会被保留下来随这个线程一起被切换走

所以线程A被切换的时候属于它上下文中那个值为1的al寄存器也被切走了注意这里切走的是数据al寄存器本身作为硬件有且只有一个

由此看来真正获取锁的操作其实只有xchgb一条交换指令来完成保证加锁操作只由一条汇编语句实现就能保证该操作的原子性

解锁的方法就很简单了movb将1写回mutex变量即可也是一条汇编完成而且一般情况下解锁是不会有执行流和你抢的。

其实加锁远不止一种方法锁的种类有非常多还有总线锁、旋转锁等等每一个锁的实现都不太一样上面提到的为互斥锁

4.5.2 总线锁

现在的CPU一般都有自己的内部缓存根据一些规则将内存中的数据读取到内部缓存中来以加快频繁读取的速度。现在服务器通常是多 CPU更普遍的是每块CPU里有多个内核而每个内核都维护了自己的缓存那么这时候多线程并发就会存在缓存不一致性这会导致严重问题。

img

总线锁就是将cpu和内存之间的通信锁住使得在锁定期间其他cpu处理器不能操作其他内存中数据故总线锁开销比较大

总线锁的实现是采用cpu提供的LOCK#信号当一个cpu在总线上输出此信号时其他cpu的请求将被阻塞那么该cpu则独占共享内存相当于锁住了

  • 何为总线

CPU总线是所有CPU与芯片组连接的主干道负责CPU与外界所有部件的通信包括高速缓存、内存、北桥其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输

image-20230103115306140

5.死锁

死锁就是一种因为两放都不会释放对方需要的资源从而陷入的永久等待状态

5.1 死锁情况演示

举个例子张三拿了锁A申请锁B的时候发现锁B无法申请而进入等待李四拿了锁B接下来他想申请锁A结果发现张三拿着锁A那就只能进入等待。这就陷入了一个僵局张三想要李四的李四想要张三的谁都不让谁

#include<iostream>
#include<string.h>
#include<signal.h>
#include<pthread.h>
#include<thread>
#include<unistd.h>
#include<sys/types.h>
#include<sys/syscall.h>
using namespace std;

pthread_mutex_t m1;//锁1
pthread_mutex_t m2;//锁2

void* func1(void*arg)
{
    while(1)
    {
        pthread_mutex_lock(&m1);
        pthread_mutex_lock(&m2);

        cout << "func1 is running... " <<(const char*)arg<<endl;

        pthread_mutex_unlock(&m1);
        pthread_mutex_unlock(&m2);
    }
}
void* func2(void*arg)
{
    while(1)
    {
        pthread_mutex_lock(&m2);
        pthread_mutex_lock(&m1);

        cout << "func2 is running... " <<(const char*)arg<<endl;

        pthread_mutex_unlock(&m1);
        pthread_mutex_unlock(&m2);
    }
}

int main()
{
    pthread_mutex_init(&m1,nullptr);
    pthread_mutex_init(&m2,nullptr);

    pthread_t t1,t2;
    pthread_create(&t1,nullptr,func1,(void*)"t1");
    pthread_create(&t2,nullptr,func2,(void*)"t2");

    //分离
    pthread_detach(t1);
    pthread_detach(t2);

    while(1)
    {
        cout << "main running..." <<endl;
        sleep(1);
    }

    pthread_mutex_destroy(&m1);
    pthread_mutex_destroy(&m2);
    return 0;
}

上面的这个代码便能模拟出这个情况线程1先要了锁1再要锁2线程2先要锁2再要锁1他们俩就容易打起来造成死锁。

运行代码的时候我们却发现似乎并不是这样的线程1好像还是成功拿到了俩把锁并运行了起来

[muxue@bt-7274:~/git/linux/code/22-12-23_线程死锁]$ ./test
main running...
func1 is running... t1
func1 is running... t1
main running...
func1 is running... t1
main running...
func1 is running... t1
main running...

那是因为我们没有执行其他一些工作从而将线程1和2申请锁的时间错开

将代码改成下面这样利用usleep让两个线程休眠不同时间结果就不同了

void* func1(void*arg)
{
    while(1)
    {
        pthread_mutex_lock(&m1);
        usleep(200);
        pthread_mutex_lock(&m2);

        cout << "func1 is running... " <<(const char*)arg<<endl;

        pthread_mutex_unlock(&m1);
        pthread_mutex_unlock(&m2);
    }
}
void* func2(void*arg)
{
    while(1)
    {
        pthread_mutex_lock(&m2);
        usleep(300);
        pthread_mutex_lock(&m1);

        cout << "func2 is running... " <<(const char*)arg<<endl;

        pthread_mutex_unlock(&m1);
        pthread_mutex_unlock(&m2);
    }
}

可以看到此时只有主线程在运行线程t1和t2出现了死锁

[muxue@bt-7274:~/git/linux/code/22-12-23_线程死锁]$ ./test
main running...
main running...
main running...
main running...

QQ图片20220519220428

5.2 死锁的条件

  • 互斥条件某份资源同一时间只能由一个执行流访问
  • 请求与保持一个执行流因请求某种资源进入阻塞等待而不释放自己的资源好比上面代码例子中两个线程都不释放自己的锁又想要别人的锁
  • 不剥夺条件一个执行流已获得的资源在未使用之前不能被剥夺部分锁是允许被剥夺的
  • 循环等待若干执行流之间形成一种头尾相接的循环等待资源的状态

一把锁也能造成死锁吗答案是肯定的

pthread_mutex_lock(&m1);
pthread_mutex_lock(&m1);
//两次申请同一把锁

如果有人写出这种bug代码那就会出现一把锁把自己死锁了死锁本来就是代码的bug所以这种低级错误也是死锁的情况之一😂

5.3 避免死锁

避免死锁其中最简单明了的办法就是破坏上面提到的死锁的4个条件其中互斥条件没啥好办法破坏除非你不加锁更主要的是看另外3个条件是否能破坏

  • 保持加锁顺序一致不要出现上面代码中的线程a先申请锁1线程b先申请锁2的情况。在不同的执行流中按相同的顺序申请锁比如线程a和b都是按锁1/2的顺序申请的一定程度上能破坏请求与保持条件
  • 降低加锁的粒度锁保护的区域变小加锁的粒度减小能一定程度上避免锁未释放
  • 资源一次性分配减少临时资源分开给的情况
  • 允许抢占线程之间依靠优先级抢夺锁这种情况就是锁允许被剥夺

6.线程安全

线程安全多个线程并发执行同一段代码的时候不会出现不同的结果

线程不安全的情况

  • 不保护临界资源
  • 在多线程操作中调用不可重入函数概念见linux信号部分
  • 返回指向静态变量的指针的函数

线程安全

  • 每个线程只操作局部变量或者只对全局、静态变量只读不写
  • 接口对线程来说是原子操作被锁保护
  • 多个线程切换不会使函数接口的结果出现二义性
  • 多线程操作不调用不可重入函数

注意绝大多数的系统自带的库比如C++的STL库都是不可重入

QQ图片20220512164211

不可重入是函数的一种性质并不是它的缺点如果一个库函数明明告知你了我是不可重入的你还不加保护的在多线程操作中调用它那么这段代码是有bug的并不是库函数本身有问题

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: linux