【Linux】线程互斥:多线程改同一个变量,到底会出什么问题

自律是普通人最大的武器

系列专栏 直达链接
🐧 Linux 进入专栏 →
🔤 C语言 进入专栏 →
🔧 C++ 进入专栏 →
🐬 MySQL 进入专栏 →
🧩 算法刷题 进入专栏 →

🏠 博客主页:虚无的纽扣

📦 作者仓库:github | gitee

目录

引言

  上一篇我们建立了这样的认知:同一进程内的所有线程共享整个地址空间,代码区、全局数据区、堆区、文件描述符表,大家都是一起看的。共享带来了高效,但也埋下了一颗雷------当多个线程同时对同一份数据进行修改时,会发生什么?这一篇我们就从一段"多线程抢票"的代码出发,看看不加保护的共享资源会闯出什么祸,再引出解决问题的第一件武器:互斥锁。

1、线程共享带来的问题

  先把几个概念立起来。多个执行流共同访问的公共资源,叫做临界资源;每个线程内部访问临界资源的代码,叫做临界区;而互斥的意思是:任何时刻,有且只有一个执行流进入临界区访问临界资源,互斥就是对临界资源起保护作用的手段。用一个生活场景类比:ATM 机取钱,一台机器只能让一个人操作,后面的人只能排队,直到前一个人用完离开------这就是互斥。下面用一段代码演示临界区:全局的总票数就是共享资源,if 语句内对票的访问就是临界区,其他与票无关的代码就是非临界区。

c 复制代码
int ticket = 1000;              // 共享资源
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        if (ticket > 0)         // 1、判断 ------ 从这里到 ticket-- 都是临界区
        {
            usleep(1000);       // 模拟抢票花的时间
            printf("%s sells ticket:%d\n", id, ticket); // 2、抢到了票
            ticket--;           // 3、票数--
        }
        else
        {
            break;
        }
    }
    return nullptr;
}

  如果对这段代码不加任何控制,多个线程并发执行,经常会出现两个线程打印出同一个票数、甚至票数被减成负数的情况。原因在于 CPU 运行速度极快,几个线程的判断和减减操作会交错执行------重复打印和负数,对应的是两种不同的竞态,机理第 2 节分别拆解。总之一句话:这就是共享资源没被保护的结果。

  要保护共享资源,办法是给临界区加上一把"锁":任意线程在访问临界资源之前先申请锁,申请到锁的线程访问临界资源,在此期间其他线程都被挡在锁外面;访问结束释放锁,下一个线程才能重复"加锁、访问、解锁"的过程。

2、为什么抢票代码不安全

  多线程抢票为什么会减到负数?进入函数后,真正影响抢票逻辑的只有两处:一是判断语句,决定走不走抢票流程;二是减减,修改全局变量的值。先看 ticket--。在冯诺伊曼体系下,这个全局变量保存在内存里,对它的减减属于算术运算,只能由 CPU 来完成,而 CPU 计算时要经历三步:第一步读取,把变量从内存读到 CPU 中;第二步计算,在 CPU 内完成减减;第三步写回,把计算完的值写回内存。也就是说,一条 C 语句在底层至少对应三步操作(实际还不止,这里先简化成三步理解)。

  风险就藏在这三步之间。假设 ticket 当前是 100,线程 A 执行完读取和计算,正要把 99 写回内存时被切换了------CPU 保存 A 的上下文(包括已经算出的 99 和下一条要执行的指令),把它放进等待队列。线程 B 上来执行,它看到内存里还是原值 100,于是完整走完了整个流程,不断减票直到只剩 1;当 B 刚把 1 载入寄存器又被切走,A 恢复上下文回来,接着执行自己没做完的写回操作,直接把内存里的变量覆盖成了 99。已经卖到只剩 1 张的票,凭空又变回 99 张------数据不一致就这么发生了。

  所以结论是:一个整数的全局变量,它的减减操作并不是原子的。所谓原子,就是这个操作要么完成、要么没做,不存在中间状态。C 语言的加加减减会被转化成三条汇编,而这三条汇编之间的任意一个位置,线程都可能被中断切换,换入下一个线程;又因为线程之间资源是共享的,中间状态就会被别的线程看到,所以它不是原子的。反过来说,当前我们理解的单条汇编语句就是原子的,多条汇编就可能遇到切换问题。

  再回到抢票负数的主要矛盾上------其实 ticket-- 并不是减出负数的元凶,真正的问题是 if 判断语句。判断一个数是否大于零也是一种计算,叫逻辑计算,同样要把 ticket 载入 CPU。有没有可能:某个线程刚把 ticket=1 载入寄存器、通过了判断还没来得及减,就被切走了?完全可能。此后每个线程都拿着自己载入的"1"通过了判断,等第一个线程把票减到 0 时,后面的线程毫不知情------判断用的是它们各自寄存器里保存的旧值 1,可轮到执行减减时,减减的第一步会重新从内存读取,此时读到的已经是 0,于是 0−1=−1 写回内存,负数就这么出来了。所以说,不安全的不只是减减,判断和减减之间的间隙同样致命。

  全局资源不加保护,执行时就可能有并发问题,这类问题统称线程安全问题。顺带一提线程切换的时机:时间片到了、阻塞式 IO、挂起或休眠,都会让线程被切走;具体的检查点在从内核态返回用户态的时候。

3、互斥锁

  pthread_mutex_t 被称为互斥锁,它有两种使用方式。第一种是定义全局的锁,用 PTHREAD_MUTEX_INITIALIZER 宏初始化,这样定义的锁不需要释放,程序运行结束会自动释放;如果想定义局部的锁,就要用 pthread_mutex_init 接口初始化,第二个参数直接设为空即可,将来这把锁不要了,需要用户手动调用 pthread_mutex_destroy 释放。

c 复制代码
#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;

  锁定义出来之后,不管是全局的还是局部的,任何线程在访问公共资源之前都得先申请锁。申请和释放锁对应的接口有三个,其中 trylock 是尝试加锁、拿不到就返回而不阻塞,我们主要用 lock 和 unlock。

c 复制代码
#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);

  把锁加到抢票代码上,就是这样:

c 复制代码
int ticket = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    return nullptr;
}

  这样每个线程在进入临界区之前先加锁,保证同一时刻只有它一个在访问临界区,访问结束时把锁打开,下一个线程继续加锁访问。注意 else 分支里也要解锁------ break 之前如果不把锁还回去,别的线程就永远拿不到锁了。

4、理解锁

  锁的接口用起来简单,但有几个值得深挖的问题。第一个问题:所有的线程都得竞争申请锁,而要申请锁,就得先看到锁------锁本身就是临界资源!锁保证了临界资源的安全,那谁来保证锁的安全?答案只能是:申请锁的过程必须是原子的,这是锁能自洽的前提。第二个问题:锁提供的能力,本质是什么?是让执行临界区代码从并行转化为串行------本来大家一窝蜂涌进去,现在排成一队一个个来。

  第三个问题:加锁之后,临界区内部还允许线程切换吗?切换了会怎么样?在操作系统眼里,加没加锁都只是代码,所以是允许切换的。那会有问题吗?不会------因为这个线程并没有释放锁,它是持着锁被切换的。即便它暂时不在 CPU 上,其他线程也得等它回来执行完临界区代码、释放锁之后,才有资格去竞争。换句话说,其他线程不关心持锁线程执行到了哪一步,它们只看锁的状态:要么有锁,要么没锁。对一个持锁线程来说,执行期间不会被打扰,这也是一种变相的原子性------前面说的"一条汇编就是原子"是语句层面的理解,这里是临界区层面的理解。

  锁在底层是怎么实现的?硬件级的实现思路是关闭时钟中断,让这段代码执行期间根本不会被切走;软件级的实现,则依赖大多数体系结构都提供的一条特殊指令:swap 或 exchange,它把寄存器和内存单元的数据相交换,只有一条指令,天然保证了原子性。

  对照图中的伪代码:我们可以暂时把锁看成一个内存中的整数:1 表示锁没人持有,0 表示锁被占用了。加锁的第一步先把 0 写入寄存器,第二步用交换指令把寄存器和内存中 mutex 的值互换。如果交换后寄存器内容大于 0,说明你换到了那个 1,申请锁成功,继续执行临界区代码;如果是 0,说明锁在别人手里,挂起等待,醒来后 goto 回去继续尝试申请。

  这里有一个重要前提要重新强调:CPU 内的寄存器硬件只有一套(先以单核视角理解;多核下每个核心各有一套寄存器,但"换进来的 1 属于当前执行流"的结论不变),寄存器里的数据却可以有多份------每个执行流各一份,这就是它们的上下文。所以把一个变量的内容交换到 CPU 寄存器,本质是把该变量的内容取到当前执行流的硬件上下文中,而这份上下文是当前线程私有的。正因为交换是"拿走"而不是"拷贝",内存里那个 1 只有一份:线程 A 一旦执行完 xchgb,交换进来的 1 就彻底属于 A 了,之后无论它在什么时刻被切换,这个事实都不会改变------其他线程拿不到 1,A 的 1 也丢不了。谁成功执行了交换指令,谁就持有锁。

  至于解锁,只要把 1 写回 mutex 就行:所有线程申请锁时都会先把自己的寄存器清零再去交换,写回的 1 不受影响,下一个来交换的线程就能换到它。

结语

  这一篇我们从抢票的 bug 出发,弄清了两件事:为什么不加保护的全局变量不安全(减减和判断都不是原子的),以及互斥锁是怎么解决问题的(用一条交换指令把锁的申请变成原子操作,让临界区从并行变串行)。不过纯互斥只解决了"对不对"的问题,没解决"好不好"的问题------一个线程如果反复申请释放锁却不干正事,其他线程可能饿死。下一篇我们讲线程同步,看看条件变量怎么让线程们按秩序排队。

相关推荐
西安同步电子1 小时前
时统卡驱动踩坑:Windows 和 Linux 下时间同步的底层差异
linux·stm32·单片机
CodeGL1 小时前
13、Python基础之网络编程
网络·1024程序员节
~城南花已开~1 小时前
25G网卡 Bond2 (Balance-XOR) 极限测试:28Gbps 并发与故障无缝切换实战
1024程序员节
Purple Coder1 小时前
超导性能对比
1024程序员节
小此方1 小时前
Re:Mysql数据库基础篇(四):一文吃透MySQL数据类型:精度陷阱、长度限制与存储机制
1024程序员节
lazy H1 小时前
【计算机网络】第 5 篇:TCP 和 UDP 有什么区别?从 Java 后端场景理解两种传输协议
经验分享·后端·网络协议·tcp/ip·计算机网络·1024程序员节
*小海豚*1 小时前
C盘占用大,windows系统清理C盘各软件 MSI 安装/修复缓存
1024程序员节
域智盾-终端安全管理1 小时前
终端安全管理系统:从“边界信任”到“终端零信任”的工程化落地
1024程序员节·终端安全管理系统
xiaogai_gai1 小时前
旺店通采购订单查询接口字段手册:queryWithDetail 实战权威教程
1024程序员节