多线程(上)

Linux线程概念

什么是线程

  • 在一个程序里的一条执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”

  • 一切进程至少都有一个执行线程

  • 线程在进程内部运行,本质是在进程地址空间内运行

  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化

  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

    image-20211009124106951

    Linux不提供线程的创建

线程的优点

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

线程的缺点

  • 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

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

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

  • 编程难度提高

    编写与调试一个多线程程序比单线程程序困难得多

线程异常

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

线程用途

  • 合理的使用多线程,能提高CPU密集型程序的执行效率
  • 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

Linux进程VS线程

进程和线程

  • 进程是资源分配的基本单位

  • 线程是调度的基本单位

  • 线程共享进程数据,但也拥有自己的一部分数据:

    • 线程ID
    • 一组寄存器(重点),有自己的硬件上下文
    • 栈(重点),有自己的栈空间,共有的栈空间会变得混乱
    • errno
    • 信号屏蔽字
    • 调度优先级

进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中
都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

进程和线程的关系如下图:

image-20211009124718672

关于进程线程的问题

如何看待之前学习的单进程?具有一个线程执行流的进程

Linux线程控制

POSIX线程库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
  • 要使用这些函数库,要通过引入头文件<pthread.h>
  • 链接这些线程函数库时要使用编译器命令的“-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:传给线程启动函数start_routine的参数
返回值:成功返回0;失败返回错误码

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小
#include<stdio.h>
#include<pthread.h>
#include<stdlib.h>
#include<unistd.h>

void* pthread_handler(void *arg)
{
    //线程1
  while (1)
  {
    printf("I am thread 1\n");
    sleep(1);
  }

  
}

int main()
{
  pthread_t tid;
  int ret;
    
  //创建线程
  ret = pthread_create(&tid, NULL, pthread_handler, NULL);
  if (ret < 0)
  {
    perror("pthread_creat");
    exit(1);
  }

    //主线程
  while (1)
  {
    printf("I am main thread\n");
    sleep(1);
  }

  return 0;
}

生成可执行文件时,要加-lpthread选项,也可以写成 -pthread,但不推荐。要让编译器知道我们使用的是哪个具体的库。

为什么不加-I和-L选项呢

因为该头文件和库的路径在系统默认路径下

结果:

image-20211009130947599

进程ID和线程ID

  • 在Linux中,目前的线程实现是Native POSIX Thread Libaray,简称NPTL。在这种实现下,线程又被称为轻量 级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct结构体)。
  • 没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程ID。但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的进程描述符,进程和内核的描述符一下子就变成了1:N关系,POSIX标准又要求进程内的所有线程调用getpid函数时返回相同的进程ID,如何解决上述问题呢?
  • Linux内核引入了线程组的概念

图中的tgid,含义是Thread Group ID,该值对应的是用户层面的进程ID ,gettid可以获取当前线程的id

image-20211008194605177

现在介绍的线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一标识线程的一个整型变量。如何查看一个线程的ID呢?

[root@localhost linux]# ps -eLf |head -1 && ps -eLf |grep a.out |grep -v grep
UID PID PPID LWP C NLWP STIME TTY TIME CMD
root 28543 22937 28543 0 2 15:32 pts/0 00:00:00 ./a.out
root 28543 22937 28544 0 2 15:32 pts/0 00:00:00 ./a.out

ps命令中的-L选项,会显示如下信息:

  • LWP:线程ID,既gettid()系统调用的返回值。

  • NLWP:线程组内线程的个数

可以看出上面a.out进程是多线程的,进程ID为28543,进程内有2个线程,线程ID(LWP的ID)分别为28543,28544

image-20211009132111628

Linux提供了gettid系统调用来返回其线程ID,可是glibc并没有将该系统调用封装起来,在开放接口来共程序员使用。如果确实需要获得线程ID,可以采用如下方法: #include <sys/syscall.h> pid_t tid; tid = syscall(SYS_gettid);

  • 从上面可以看出,a.out进程的ID为28543,下面有一个线程的ID也是28543,这不是巧合。线程组内的第一个线程,在用户态被称为主线程(main thread),在内核中被称为group leader,内核在创建第一个线程时,会将线程组的ID的值设置成第一个线程的线程ID,group_leader指针则指向自身,即主线程的进程描述符。所以线程组内存在一个线程ID等于进程ID,而该线程即为线程组的主线程

    /* 线程组ID等于线程ID,group_leader指向自身 */
    p->tgid = p->pid;
    p->group_leader = p;
    INIT_LIST_HEAD(&p->thread_group);
    
  • 至于线程组其他线程的ID则由内核负责分配,其线程组ID总是和主线程的线程组ID一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。

    if ( clone_flags & CLONE_THREAD )
    	p->tgid = current->tgid;
    if ( clone_flags & CLONE_THREAD ) 
    {
        P->group_lead = current->group_leader;
        list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
    }
    
  • 强调一点,线程和进程不一样,进程有父进程的概念,但在线程组里面,所有的线程都是对等关系

    image-20211009132428144

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

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是
    一回事。

  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要 一个数值来唯一表示该线程。

  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于 NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。

  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:

    pthread_t pthread_self(void);
    

    pthread_t到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。 也就是对应下图中每一个结构体pthread的内存的起始地址。

    image-20211009133358606

因为Linux不会创建线程,所以线程是用户创建和管理的, 内核中由底层的LWP(执行流)执行
pthread库既然创建了线程,就要负责管理线程:描述(结构体TCB),组织。
TCB中有一个pid数据标识线程的id,指向轻量级进程(LWP),LWP帮我们完成线程的执行功能。

image-20211009134349062

进程id,内核线程id,pthread线程id打印:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>

void* handler(void* arg)
{
  while (1)
  {
    printf("i am thread... 进程id:%d, 线程id:%ld, pthread_id:%ld\n", getpid(), syscall(SYS_gettid), pthread_self());

    sleep(1);
  }
}

int main()
{
  pthread_t tid;
  int ret = 0;

  ret = pthread_create(&tid, NULL, handler, NULL);
  if (ret < 0)
  {
    perror("pthread_create error!");
    return 1;
  }

  //主线程
  while (1)
  {
    printf("i am main thread... 进程id:%d, 线程id:%ld, pthread_id:%ld\n", getpid(), syscall(SYS_gettid), pthread_self());
    sleep(1);
  }

  return 0;
}

image-20220123095247890

线程等待 为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,会造成类似僵尸进程的结果。
  • 创建新的线程不会复用刚才退出线程的地址空间
功能:等待线程结束
原型
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(值为-1)。
  • 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  • 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数

image-20211010233639716

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thread1(void *arg)
{
    printf("thread 1 returning ... \n");
    int *p = (int*)malloc(sizeof(int));
    *p = 1;
    
    return (void*)p;
} 

void *thread2(void *arg)
{
    printf("thread 2 exiting ...\n");
    int *p = (int*)malloc(sizeof(int));
    *p = 2;
    
    pthread_exit((void*)p);
} 

void *thread3(void *arg)
{
    while ( 1 )
    { 
        printf("thread 3 is running ...\n");
        sleep(1);
    } 
    
    return NULL;
}
int main( void)
{
    pthread_t tid;
    void *ret;
    // thread 1 return
    pthread_create(&tid, NULL, thread1, NULL);
    pthread_join(tid, &ret);
    printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);
    
    
    // thread 2 exit
    pthread_create(&tid, NULL, thread2, NULL);
    pthread_join(tid, &ret);
    printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);
    
    
    // thread 3 cancel by other
    pthread_create(&tid, NULL, thread3, NULL);
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid, &ret);
    if ( ret == PTHREAD_CANCELED )
    	printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
    else
    	printf("thread return, thread id %X, return code:NULL\n", tid);
} 

运行结果:
[root@localhost linux]# ./a.out
thread 1 returning ...
thread return, thread id 5AA79700, return code:1
thread 2 exiting ...
thread return, thread id 5AA79700, return code:2
thread 3 is running ...
thread 3 is running ...
thread 3 is running ...
thread return, thread id 5AA79700, return code:PTHREAD_CANCELED

线程终止

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

  • 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
  • 线程可以调用pthread_ exit终止自己。如果线程直接调用exit函数,会使整个进程退出,所以exit终止的是进程
  • 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

注意:线程的退出只关心执行结果正不正确,因为当线程异常退出时,进程也退出了。

pthread_exit函数

功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不需要指向一个局部变量。一般是将结果强转为指针,在读取退出信息时再强转为int/long类型
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

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

使用示例:

#include<stdio.h>
#include<pthread.h>

void* handle(void*arg)
{
  int i = 0;
  while(1)
  {
    if (5 == i)
      break;

    sleep(1);
    printf("this is a thread: %d\n", pthread_self());
    ++i;
  }

  pthread_exit((void*) 10);//设置线程的退出码为10
}

int main()
{
  pthread_t tid;  

  pthread_create(&tid, NULL, handle, NULL);
  
  void*ptr = NULL;
  pthread_join(tid, &ptr);//主线程等待新线程

    //打印退出码
  printf("exit code:%d\n", (long long)ptr);

  return 0;
}

结果:

image-20211010232104907

pthread_cancel函数

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

被取消的线程会返回将宏PTHREAD_CANCELED(值为-1)返回给等待它的线程(等待接下来就讲)

不一定是只由主线程取消,也可以由其他进程取消,当然,也可以自杀(自己取消自己,这种行为很奇怪)

示例:

#include<stdio.h>
#include<pthread.h>

void* handle(void*arg)
{
  int i = 0;
  while(1)
  {
    //if (5 == i)
     // break;

    sleep(1);
    printf("this is a thread: %d\n", pthread_self());
    ++i;
  }

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

int main()
{
  pthread_t tid;  

  pthread_create(&tid, NULL, handle, NULL);
  
  sleep(10);
  //主线程等10s再取消新线程
  pthread_cancel(tid);
  
  void*ptr = NULL;
  //获取退出码
  pthread_join(tid, &ptr);

  printf("exit code:%d\n", (long long)ptr);

  return 0;
}

结果:

image-20211010232009828

注意:主线程调用pthread_cancel函数, 则主线程的状态变更成为Z, 其他线程不受影响

分离线程

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

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

joinable和分离是冲突的,一个线程不能既是joinable又是分离的

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thread_run( void * arg )
{
    //自己分离
    pthread_detach(pthread_self());
    printf("%s\n", (char*)arg);
    return NULL;
} 

int main( void )
{
    pthread_t tid;
    if ( pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0 ) //创建线程
    {
        printf("create thread error\n");
        return 1;
    } 
    
    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线程互斥

进程线程间的互斥相关背景概念

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

互斥量mutex

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

首先我们要明白,线程之间的代码和数据都是共享的,不同于进程有写时拷贝,所以一个线程修改了数据,其他线程看到的是同一份数据,因此获取的数据也是被修改的。

// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

void *get_ticket(void *arg)
{
    int id = (int)arg;
    while (1) 
    {
    	if ( ticket > 0 ) 
        {
            usleep(1000);
            printf("thread %d sells ticket:%d\n", id, ticket);
            ticket--;
        } 
        else 
        {
            break;
        }
	}
} 

int main( void )
{
    //循环创建进程
    pthread_t arr[4];
    int i = 0;
    for (; i < 4; ++i)//创建四个线程
    {
        //arr+i就是每个元素的地址,所以不用再取地址
        pthread_create(arr + i, NULL, get_ticket, (void*)i);
    }
    
    //抢票等待
    for (i = 0; i < 4; ++i)
    {
        pthread_join(arr[i], NULL);
    }
    
    
    return 0;
} 

一次执行结果:
thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

为什么可能无法获得争取结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket操作本身就不是一个原子操作

ticket–的底层汇编实现:

//取出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>

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

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

因此,当多个进程同时进入if时,进行ticket–,如果此时ticket为1,而某个线程对其–了,ticket就为0了,而其他线程还在if里面,于是还会对ticket–,并且会抢票成功,这样即使当ticket为0时,本已经不能抢票了,结果最后还是多抢了几张不存在的票。

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

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

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

image-20211010232453764

互斥量的接口

初始化互斥量

初始化互斥量有两种方法:

  • 方法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调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

改进上面的售票系统:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>

int ticket = 10000;

pthread_mutex_t mutex;

void *get_ticket(void *arg)
{
    usleep(1000);
    int num = (int)arg;
    while (1) 
    {
        pthread_mutex_lock(&mutex);
    	if ( ticket > 0 ) 
        {
            usleep(1000);
            printf("thread:%d sells ticket:%d\n", num + 1, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        } 
        else 
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
	}
} 

int main( void )
{
    //锁的初始化
    pthread_mutex_init(&mutex, NULL);
    
    //循环创建进程
    pthread_t arr[4];
    int i = 0;
    for (; i < 4; ++i)
    {
        //arr+i就是每个元素的地址,所以不用再取地址
        pthread_create(arr + i, NULL, get_ticket, (void *)i);
    }
    
    //抢票等待
    for (i = 0; i < 4; ++i)
    {
        pthread_join(arr[i], NULL);
    }
    
    //销毁锁
    pthread_mutex_destroy(&mutex);
}

当别的线程已经申请好锁了,期间有其他线程来申请锁,是申请不到的。于是,新线程就要进入阻塞状态。

关于阻塞的补充:将进程/线程对应的PCB投入到等待队列中,将R状态改为S状态。解锁后,一次性唤起一个线程申请锁。

mutex的理解

在POSIX库中,要管理锁,所以mutex是一个结构体,里面包含了两个内容:1.int lock 2.wait_queue。
lock就是表示当前锁是否被占用。0表示没有被占用,1表示被占用。
当有一个线程占用锁了,其他线程想再申请这个锁,就要进入等待队列,也就是wait_queue。
之前所学的pthread_mutex_init就是将lock置为0,wait_queue有一个head指针,将head指针置为NULL。pthread_mutex_lock就是将lock置为1,pthread_mutex_unlock就是将lock置为0

这样就能保证一次只有一个线程进入临界区,访问临界资源,这就是互斥。

互斥量实现原理探究

我们先思考一个问题:在一个线程执行临界区代码时(已经占用了锁),线程时间片到了,于是该线程被切换下CPU暂停运行,这样会影响该线程的正常运行吗?

不影响,因为临界资源已经被它锁上了,即使它没有运行,其他线程也无法访问这些临界资源。

经过上面的例子,大家已经意识到单纯的i++或者++i都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下

image-20211021233807400
lock:
		movb $0, %al
		xchgb %al, mutex
		if (al寄存器的内容 > 0)
		{
			return 0;
		}
		else
		{
			挂起等待;
			goto lock;
		}
		
unlock:
		movb $1, mutex
		唤醒等待Mutex的线程

对该段伪代码的理解:

现在我们有一个线程A,它申请对一份临界资源上锁,于是执行 movb $0, %al,al是属于该线程的寄存器,将值0放入该寄存器中,然后执行 xchgb %al, mutex,将寄存器al中的数据与mutex(一个变量,类似上面说的lock)中的数据交换,mutex中的值默认为1,交换后,mutex中的值就变成了0,%al中的值就变成了1。此时,线程A的时间片到了,要切换到线程B运行,线程A退出时,在CPU中属于线程A的寄存器数据(上下文数据)需要被保存,但注意,mutex不属于上下文数据,它是内存中的数据。线程B运行时,也要申请同一份临界资源的锁,于是执行 movb $0, %al,将0值放到寄存器al中,注意,这里al在线程B运行时是属于B的,上下文保存的是进程/线程运行时所使用寄存器中的值,当切换线程/进程时,这些寄存器照样可以被使用,所以当下次线程A再运行时,这些数据又会被返回到对应的寄存器中。所以当线程B执行完 movb $0, %al后,就要执行 xchgb %al, mutex了,此时mutex的值是0,与寄存器%al交换还是0,所以进入下面的判断时,会进入else,从而进入等待。此时线程A从等待队列中出列,将保存的上下文数据返回到%al中,于是%al中就是1了,继续运行,从if开始,进入if,成功申请锁。

通过上面的过程,我们认识到

  1. 整个过程中,为1的mutex只有一份
  2. exchange一条汇编完成了寄存器和内存数据的交换
  3. 所以pthread_mutex_lock是具有原子性的

当然,解锁也一定是具有原子性的,因为只有已经申请锁成功的线程才有解锁的权利,其他线程不能执行unlock。

关于上下文数据的存放:

以上面的线程A、B为例,线程A、B分别有属于自己的结构体TSS,其中存放着对应所有寄存器的变量。当一个线程/进程时间片到了退出运行时,当前已经占用的寄存器的值会对应放到该结构体中,下次运行时,再将这些数据放入对应的寄存器继续运行。

image-20211012134941662

可重入VS线程安全

概念
  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况

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

常见不可重入的情况

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

常见可重入的情况

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

可重入与线程安全联系

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

可重入与线程安全区别

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

死锁

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

也就是线程A占有了一块资源并上了锁,线程B占有了另外一块资源也上了锁,此时线程A和线程B都想再申请对方已经占有并上锁的资源,而双方又不肯释放已经占有的资源,于是两个线程就干耗着。这种现象就称为死锁。

死锁四个必要条件

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

避免死锁

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

避免死锁算法

死锁检测算法
银行家算法

有兴趣的同学可以自己去了解一下

  • 15
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 21
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 21
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

WoLannnnn

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

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

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

打赏作者

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

抵扣说明:

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

余额充值