【Linux】线程同步和互斥(1):线程互斥与加锁实现

目录

[一 线程互斥](#一 线程互斥)

[1 进程线程间的互斥相关背景概念](#1 进程线程间的互斥相关背景概念)

[2 互斥量mutex](#2 互斥量mutex)

[3 两个问题:](#3 两个问题:)

(1)为什么会抢到负数?

(2)如何解决这个问题

[4 加锁](#4 加锁)

(1)操作

(2)锁的本质

[5 mutex的封装](#5 mutex的封装)


一 线程互斥

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

共享资源

临界资源:多线程执行流中需要被保护的共享资源 ,就叫做临界资源

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

互斥:任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

多线程在访问共享资源时容易出现问题。线程之间会共享文件描述符表和已打开的文件结构体,多个线程实际上指向同一份文件表项。主线程默认会打开终端对应的标准输出文件描述符 1;多线程向终端输出内容,本质上就是多个线程同时对同一个文件进行写入操作。因此,终端显示器对应的文件属于多线程间的共享资源,并发访问时需要注意同步问题。

所以共享资源会导致数据不一致问题

未来要把共享资源保护起来的方法:同步,互斥

想保护临界资源必须先知道是怎么访问临界资源的。保护临界资源就是保护访问临界资源的代码----->叫做临界区

2 互斥量mutex

多个线程并发的操作共享共享变量。会带来一些问题

我们来写一个多线程抢票的代码--->模拟多线程访问并发资源,来看一下会带来哪些问题

cpp 复制代码
// 操作共享变量会有问题的售票系统代码 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
 char *id = (char*)arg;
 while ( 1 ) {
 if ( ticket > 0 ) {
 usleep(1000);
 printf("%s sells ticket:%d\n", id, ticket);
 ticket--;
 } else {
 break;
 }
 }
}
int main( void )
{
 pthread_t t1, t2, t3, t4;
 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_create(&t4, NULL, route, (void*)"thread 4");
 pthread_join(t1, NULL);
 pthread_join(t2, NULL);
 pthread_join(t3, NULL);
 pthread_join(t4, NULL);
}

我们运行代码之后发现,票数会被抢到负数,会导致数据不一致--->线程安全中的一种

cpp 复制代码
一次执⾏结果:
 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

3 两个问题:

(1)为什么会抢到负数?

if判断本身就是一种逻辑运算,能处理逻辑运算的典型硬件是CPU

if判断要做a.变量导入到CPU的eax寄存器中,(1000和0),CPU内存在算逻运算单元

b.CPU比较1000和0,后输出为真为假,但是这个过程是以线程为载体的。所以这是一个线程调度执行的过程

当只剩一张票的时候,假设有A B C D 四个线程,当A把1加载到eax中时,A被切走。此时,A会保存eax=1,下一步要和0作比较;所以A刚被切走,B就来了,B把1加载到eax中时,B被切走......(重复上述过程)。当A再次被调度过来,对tickets做--,同样的,B C D也会做--操作;这个时候tickets就会把多的票放进来

额外话题:多线程--的话题,对全局变量--的话题:

tickets--在C中是一条语句,但是在汇编中是三条语句,对应三个动作

tickets--是一个线程调度执行的过程

因为tickets--对应三个动作:操作前,操作中,操作后;在操作的时候是可能被中断的--->所以不是原子的

结论:整型变量,不具有原子性

如果汇编条数只有一条,就是原子的;否则不是原子的

所以信号量(原子的) != 全局计数器(非原子的)

(2)如何解决这个问题

让多线程进行互斥访问

任何时刻只允许一个线程,且是原子性的访问临界区

因为有互斥的存在,所以不会存在竞争的问题

通过加锁来保护临界区

4 加锁

(1)操作

mutex--->互斥量,也是我们所说的锁

**变量类型:pthread_mutex_t:**表示互斥锁或者互斥量

定义一把锁:pthreead_mutex_t glock=PTHREAD_MUTEX_INITALIZER(全局锁);

保护临界区,一定要把被保护的区域,范围缩到最小!!! 被保护的代码量尽量少,不要保护非临界区代码,否则会造成串行执行

互斥量加锁和解锁

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);//加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁
返回值:成功返回0,失败返回错误号

如果加锁失败,线程就会阻塞;加锁成功就会立即返回-->允许访问后续代码,访问临界区

那我们来给这个抢票代码加一下锁:

cpp 复制代码
#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
 #include <pthread.h>
 #include <iostream>
 #include <string>
 
 
 int ticket = 1000;
 
 typedef struct threadData
 {
     std::string name;
     pthread_mutex_t *plock;
 }threaddata_t;
 
 void *route(void *arg)
 {
     threaddata_t *td = static_cast<threaddata_t *>(arg);
     while ( 1 ) {
         // pthread_mutex_lock(td->plock);
         if ( ticket > 0 ) {
             usleep(1000);
             printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
                         ticket--;
             // pthread_mutex_unlock(td->plock);
         } else {
             // pthread_mutex_unlock(td->plock);
             break;
         }
     }
 
     return nullptr;
 }
 
 int main( void )
 {
     pthread_t t1, t2, t3, t4;
 
     pthread_mutex_t lock;
     pthread_mutex_init(&lock, nullptr);
 
     threaddata_t data1 = {"thread-1", &lock};
     pthread_create(&t1, NULL, route, (void*)&data1);
 
     threaddata_t data2 = {"thread-2", &lock};
     pthread_create(&t2, NULL, route, (void*)&data2);
 
     threaddata_t data3 = {"thread-3", &lock};
     pthread_create(&t3, NULL, route, (void*)&data3);
 
     threaddata_t data4 = {"thread-4", &lock};
     pthread_create(&t4, NULL, route, (void*)&data4);
 
     pthread_join(t1, NULL);
     pthread_join(t2, NULL);
     pthread_join(t3, NULL);
     pthread_join(t4, NULL);
 
     pthread_mutex_destroy(&lock);
 
     return 0;
 }

此时运行之后,票就不会出现抢到负数的情况

加锁会造成一定程度上的效率降低,所以尽可能减少加锁区,减少串行执行,增加并行执行

如何定义局部锁:必须用pthread_mutex_init进行初始化,用pthread_mutex_destory进行销毁

(2)锁的本质

操作上要注意的事情:

要申请锁,必须先看到锁---->锁本身也是共享资源,申请锁的过程,必须是原子的!!

申请锁的本质,是在申请许可

申请锁,可以让线程一部分申请,一部分不申请吗?不可以!把加锁保护当成一种约定,大家都要遵守

锁的原理:

(1)通过硬件实现

我们前面学到的情况,是线程发生切换后导致的(根本原因是外部的时钟中断),你怎么做到让线程不要做任何切换?

关闭时钟中断-->相当于加锁 ,运行一段时间后(这段时间线程执行是没人打扰的,因为是原子的),打开时钟中断---->相当于解锁

关闭中断是有风险的,所以不会给用户使用,而是给内核使用

(2)通过软件实现

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

swap和exchange是一条原子的汇编语句

内存数据往往被多线程共享,比如全局的,静态的

CPU寄存器只有一套,而CPU内存寄存器的内容本质,是当前线程的硬件上下文

每个线程都有自己独立的硬件上下文

这个指令的作用是吧寄存器和内存器的数据相交换---->本质是把共享的数据内容,变成一个线程私有的内容,意味着其他线程想得到这个内容,是得不到的,除非归还。此时这里的内容就是锁

上面的是函数的底层实现代码

bash 复制代码
lock:
    movb $0, %al        ; 把寄存器%al清0
    xchgb %al, mutex    ; 原子交换:把mutex的值读入%al,同时把mutex设为0(整个过程不可被打断)
    if (%al > 0) {      ; 判断:如果从mutex读到的值>0(也就是之前mutex是1)
        return 0;       ; 加锁成功,直接返回
    } else {            ; 否则(读到的值是0,说明锁已经被别人拿走了)
        挂起等待;        ; 进入等待状态
    }
    goto lock;          ; 被唤醒后,回到开头重新尝试加锁

unlock:
    movb $1, mutex      ; 原子性地把mutex的值从0写回1,释放锁
    唤醒等待Mutex的线程;  ; 通知等待的线程:锁现在可用了
    return 0;

在物理内存里面定义一个变量: mutex=1,在CPU中有eax寄存器,值默认为0,eax就等同于%al

xchgb %al,mutex:交换mutex和%al的值,该条语句之内不能被切换,因为是原子的

当线程A执行到lock的第三条语句,时间片到了,被切走,此时A要保存它的硬件上下文-->eax=1,pc=3(表示执行到第三行代码);换到线程B,线程B首先把寄存器清0(此时eax和mutex都为0),交换%al ,mutex;eax=0,线程B被挂起,这个过程叫做竞争锁失败。线程B:eax=0,pc=5。线程A回来,写入自己eax的值,从第三条语句继续执行,eax>0,线程A解锁成功

上面的第二条语句叫做以原子性的方式申请锁

交换体系下,"1"只有一份,谁有"1",谁就能加锁成功--->"1"就是锁

上面是加锁的本质,那解锁呢?原子性的把mutex由0->1

5 mutex的封装

cpp 复制代码
#ifndef __MUTEX_HPP
 #define __MUTEX_HPP
 
 #include <iostream>
 #include <pthread.h>
 
 class Mutex
 {
 public:
     Mutex()
     {
         pthread_mutex_init(&_lock, nullptr);
     }
     void Lock()
     {
         pthread_mutex_lock(&_lock);
     }
     void Unlock()
     {
         pthread_mutex_unlock(&_lock);
     }
     ~Mutex()
     {
         pthread_mutex_destroy(&_lock);
     }
 private:
     pthread_mutex_t _lock;
 };
 
 // 锁的开关
 class LockGuard
 {
 public:
     LockGuard(Mutex *lockp): _lockp(lockp)
     {
         _lockp->Lock();
     }
     ~LockGuard()
     {
         _lockp->Unlock();
     }
 private:
     Mutex *_lockp;
 };
 
 #endif

LockGuard:RAII 风格自动锁,构造时自动加锁,析构时自动解锁,再也不会忘记解锁。

LockGuard必须和一个已经初始化的锁结合使用

在代码角度看临界区:创建一个临时变量,出了while循环临时变量自动销毁-----RAII风格的加锁逻辑

RAII 的核心就是

把资源(锁、文件、内存、连接)交给一个对象管理,利用 C++ 对象离开作用域 自动调用析构函数 的特性,自动释放资源。

你不用手动管,编译器帮你管

相关推荐
yoyo_zzm1 小时前
编程语言大比拼:C++到PHP全解析
开发语言·c++·php
山栀shanzhi1 小时前
TCP 三次握手四次挥手
服务器·tcp/ip·php
努力努力再努力wz1 小时前
【C++高阶数据结构系列】:时间轮定时器详解:原理分析与代码实现,带你从零手撕时间轮!(附时间轮的实现源码)
c语言·开发语言·数据结构·c++·qt·算法·ui
Bert.Cai2 小时前
Linux iconv命令详解
linux·运维·服务器
独隅2 小时前
详解SMTP与IMAP协议:核心区别、工作原理与全链路环境标准化实战场景应用
运维
Chen_harmony2 小时前
十九、数据在内存中的存储
c语言·开发语言
WangLanguager2 小时前
Linux命令chfn(change finger information) 详细介绍
linux·运维·服务器
basketball6162 小时前
C 的 malloc/free 与 C++ 的 new/delete 一些区别
c语言·开发语言·c++
mmz12072 小时前
广搜题目练习(c++)
c++·算法