【Linux】深入理解线程同步与互斥

#「开学季干货」:聚焦知识梳理与经验分享#

在Linux多线程编程中,线程同步与互斥是保证程序正确性的核心技术。当多个线程共享资源时,若不加以控制,会出现数据错乱、逻辑异常等问题。本文将从实际问题出发,逐步剖析线程互斥、同步的原理与实现,结合代码案例讲解关键技术,并深入探讨死锁的避免策略,帮助开发者彻底掌握多线程编程的“基石”。

一、线程互斥:解决共享资源竞争问题

要理解线程互斥,首先要明确“为什么需要互斥”。我们从一个经典的“多线程抢票”案例切入,感受未加控制的多线程访问会带来怎样的问题。

1. 问题引入:抢票案例中的数据错乱

假设我们用3个线程模拟用户抢票,初始票数为1000,每个线程判断票数大于0时就扣减1并打印结果。代码如下:

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

int tickets = 1000;  // 共享票数

// 线程执行函数:抢票逻辑
void* Ticket(void* argv) {
    while (1) {
        if (tickets > 0) {
            usleep(1000);  // 模拟耗时操作(如网络请求)
            printf("[%p]: 抢到票号:%d\n", pthread_self(), tickets--);
        } else {
            break;
        }
    }
    return nullptr;
}

int main() {
    pthread_t t1, t2, t3;
    // 创建3个抢票线程
    pthread_create(&t1, nullptr, Ticket, nullptr);
    pthread_create(&t2, nullptr, Ticket, nullptr);
    pthread_create(&t3, nullptr, Ticket, nullptr);
    // 等待线程结束
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    return 0;
}

运行结果异常
最终会出现“抢到0号票”“抢到-1号票”的情况。这是因为if (tickets > 0) tickets--并非原子操作——当线程A判断 tickets=1后进入休眠,线程B、C也可能同时判断 tickets>0,随后线程A唤醒后执行 tickets--(变为0),线程B、C再执行 tickets--就会出现负数。

这种多个线程竞争共享资源导致数据错误的现象,称为竞态条件;要解决竞态条件,就需要引入“互斥”机制。

2. 核心概念:理解互斥的关键术语

在深入实现前,先明确几个核心概念,为后续学习奠定基础:

术语定义举例
临界资源多线程环境下不能被同时访问的共享资源上述案例中的tickets变量、文件、网络端口
临界区访问临界资源的代码段上述if (tickets > 0) tickets--的代码块
原子操作不可中断的操作(要么全执行,要么全不执行)CPU的xchg(交换)指令、inc(自增)指令
互斥同一时间仅允许一个线程进入临界区访问临界资源抢票时“一次只能一个人操作票数”
并发多个线程在宏观上“同时”执行,但微观上交替切换3个抢票线程交替执行抢票逻辑

3. 解决方案:互斥量(Mutex)的使用

Linux下通过互斥量(Mutex) 实现互斥,它相当于“一把锁”:线程进入临界区前需“上锁”,退出临界区后“解锁”;若锁已被占用,其他线程会阻塞等待,直到锁被释放。

(1)POSIX线程库的互斥量接口

POSIX线程库(pthread)提供了一套完整的互斥量操作接口,所有函数均以pthread_mutex_开头:

接口功能函数原型关键说明
静态初始化pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;全局/静态互斥量使用,程序结束自动回收,无需销毁
动态初始化int pthread_mutex_init(pthread_mutex_t* restrict mutex, const pthread_mutexattr_t* restrict attr);局部互斥量使用,attrnullptr表示默认属性
销毁互斥量int pthread_mutex_destroy(pthread_mutex_t* mutex);仅动态初始化的互斥量需要调用,避免内存泄漏
加锁int pthread_mutex_lock(pthread_mutex_t* mutex);若锁已被占用,当前线程阻塞等待
解锁int pthread_mutex_unlock(pthread_mutex_t* mutex);释放锁,唤醒等待队列中的一个线程
(2)修复抢票案例:添加互斥锁

我们给临界区(抢票逻辑)添加互斥锁,确保同一时间只有一个线程操作票数。修改后的代码如下:

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

int tickets = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 静态初始化互斥锁

void* Ticket(void* argv) {
    while (1) {
        pthread_mutex_lock(&mutex);  // 进入临界区前上锁
        if (tickets > 0) {
            usleep(1000);
            printf("[%p]: 抢到票号:%d\n", pthread_self(), tickets--);
            pthread_mutex_unlock(&mutex);  // 退出临界区后解锁
        } else {
            pthread_mutex_unlock(&mutex);  // 即使无票,也要解锁后再退出
            break;
        }
    }
    return nullptr;
}

// main函数与之前一致,此处省略

运行结果正常
票数会从1000依次减少到1,不会出现负数。这是因为互斥锁保证了if判断票数扣减的原子性——只有拿到锁的线程才能进入临界区,其他线程需等待锁释放。

4. 进阶:互斥锁的实现原理与优化

(1)互斥锁的原子性保障

互斥锁的“上锁”和“解锁”本身是原子操作,依赖CPU的特殊指令实现。以x86架构为例,上锁的伪代码逻辑如下:

lock:
    movb $0, %al          ; 将al寄存器设为0
    xchgb %al, mutex      ; 交换al与mutex的值(原子操作)
    if (%al > 0) {
        return 0;         ; 交换前mutex为1,表示锁空闲,上锁成功
    } else {
        挂起当前线程;      ; 交换前mutex为0,表示锁被占用,阻塞等待
        goto lock;        ; 唤醒后重新尝试上锁
    }

unlock:
    movb $1, mutex        ; 将mutex设为1(释放锁)
    唤醒等待队列中的线程;
    return 0;

xchgb指令通过lock前缀保证原子性,避免多个CPU核心同时修改mutex的值。

(2)互斥锁 vs 自旋锁

除了互斥锁(也称“阻塞锁”),还有一种常见的同步锁——自旋锁。两者的核心区别在于“等待锁时的行为”:

  • 互斥锁:若锁被占用,当前线程会被挂起(进入阻塞状态),CPU转而执行其他线程,避免空耗CPU;但切换线程会带来“上下文切换”开销。
  • 自旋锁:若锁被占用,当前线程会循环检查锁的状态(“自旋”),直到锁被释放;无上下文切换开销,但会空耗CPU资源。

使用场景选择

  • 临界区执行时间短(如简单的变量修改):用自旋锁(避免上下文切换开销);
  • 临界区执行时间长(如IO操作、复杂计算):用互斥锁(避免CPU空耗)。

5. 工程实践:互斥锁的封装(RAII思想)

在实际开发中,手动调用pthread_mutex_lockpthread_mutex_unlock容易出现“忘记解锁”(如函数提前返回、异常抛出)的问题。我们可以用C++的RAII(资源获取即初始化)思想封装互斥锁,确保锁的自动释放。

封装代码:Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>
using namespace std;

// 互斥锁类:负责初始化和销毁
class Mutex {
public:
    Mutex() {
        // 动态初始化互斥锁(默认属性)
        int ret = pthread_mutex_init(&_mutex, nullptr);
        if (ret != 0) {
            cerr << "pthread_mutex_init failed: " << ret << endl;
        }
    }

    ~Mutex() {
        // 销毁互斥锁
        int ret = pthread_mutex_destroy(&_mutex);
        if (ret != 0) {
            cerr << "pthread_mutex_destroy failed: " << ret << endl;
        }
    }

    // 加锁(供外部调用)
    void Lock() {
        pthread_mutex_lock(&_mutex);
    }

    // 解锁(供外部调用)
    void Unlock() {
        pthread_mutex_unlock(&_mutex);
    }

    // 获取原生互斥锁(供条件变量使用)
    pthread_mutex_t* Get() {
        return &_mutex;
    }

private:
    pthread_mutex_t _mutex;  // 原生互斥锁对象
};

// 锁守卫类:负责自动加锁和解锁
class LockGuard {
public:
    // 构造时加锁
    explicit LockGuard(Mutex& mutex) : _mutex(mutex) {
        _mutex.Lock();
    }

    // 析构时解锁(函数退出、异常抛出时都会调用)
    ~LockGuard() {
        _mutex.Unlock();
    }

private:
    Mutex& _mutex;  // 引用互斥锁,避免拷贝
};
使用示例:简化抢票逻辑
Mutex mutex;  // 定义互斥锁

void* Ticket(void* argv) {
    while (1) {
        LockGuard lock(mutex);  // 构造时自动加锁
        if (tickets > 0) {
            usleep(1000);
            printf("[%p]: 抢到票号:%d\n", pthread_self(), tickets--);
        } else {
            break;  // 析构时自动解锁
        }
    }
    return nullptr;
}

通过LockGuard,无论函数正常退出还是异常退出,锁都会在析构时自动释放,彻底避免“死锁”风险。

二、线程同步:协调线程执行顺序

互斥解决了“数据安全”问题,但无法解决“顺序可控”问题。例如:生产者线程生产数据后,需要通知消费者线程立即消费;若消费者先执行,会因无数据而等待。此时需要“同步”机制协调线程执行顺序。

1. 核心概念:同步与竞态条件

  • 同步:在保证数据安全的前提下,让线程按照预设的逻辑顺序执行。例如“生产者生产后,消费者才能消费”。
  • 竞态条件:多线程执行结果依赖于线程的执行顺序,导致结果不可预测。例如“消费者先执行时,因无数据而报错”。

解决同步问题的核心工具是条件变量(Condition Variable),它允许线程在满足特定条件前阻塞,在条件满足时被唤醒。

2. 条件变量:线程的“等待-唤醒”机制

条件变量通常与互斥锁配合使用:互斥锁保证条件判断的原子性,条件变量负责线程的阻塞与唤醒。

(1)POSIX线程库的条件变量接口

POSIX线程库中,条件变量相关函数以pthread_cond_开头:

接口功能函数原型关键说明
静态初始化pthread_cond_t cond = PTHREAD_COND_INITIALIZER;全局/静态条件变量使用,无需销毁
动态初始化int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_condattr_t* restrict attr);局部条件变量使用,attrnullptr表示默认属性
销毁int pthread_cond_destroy(pthread_cond_t* cond);仅动态初始化的条件变量需要调用
等待条件int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);1. 自动释放互斥锁;2. 阻塞等待唤醒;3. 唤醒后重新获取互斥锁
唤醒单个线程int pthread_cond_signal(pthread_cond_t* cond);唤醒等待队列中的一个线程(随机)
唤醒所有线程int pthread_cond_broadcast(pthread_cond_t* cond);唤醒等待队列中的所有线程
(2)关键注意点:避免虚假唤醒与信号丢失
  • 虚假唤醒:线程被唤醒后,发现条件并未满足(如多个线程被唤醒,但资源已被其他线程占用)。例如:2个消费者线程等待“有数据”,生产者唤醒所有线程,但只有1个消费者能拿到数据,另一个消费者就是“虚假唤醒”。
    解决方法:用while循环判断条件,而非if——唤醒后重新检查条件,不满足则继续等待。

  • 信号丢失:若唤醒操作(signal/broadcast)在等待操作(wait)前执行,唤醒信号会“失效”,导致后续等待的线程永久阻塞。
    解决方法:用共享变量记录条件状态(如“数据数量”),等待前先检查状态,避免盲目等待。

3. 经典案例:生产者-消费者模型

生产者-消费者模型是同步机制的典型应用,它遵循“321原则”:

  • 3种关系:生产者与生产者(互斥)、消费者与消费者(互斥)、生产者与消费者(同步);
  • 2个角色:生产者(生产数据)、消费者(消费数据);
  • 1个交易场所:共享缓冲区(如队列、数组)。
基于阻塞队列的实现

我们用queue作为共享缓冲区,用条件变量协调生产者和消费者:

  • 生产者:缓冲区满时阻塞,有空闲时生产数据并唤醒消费者;
  • 消费者:缓冲区空时阻塞,有数据时消费数据并唤醒生产者。

代码实现(BlockQueue.hpp):

#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include "Mutex.hpp"  // 引入之前封装的互斥锁

// 条件变量封装(RAII)
class Cond {
public:
    Cond() {
        pthread_cond_init(&_cond, nullptr);
    }

    ~Cond() {
        pthread_cond_destroy(&_cond);
    }

    // 等待条件(需传入互斥锁)
    void Wait(Mutex& mutex) {
        pthread_cond_wait(&_cond, mutex.Get());
    }

    // 唤醒单个线程
    void Signal() {
        pthread_cond_signal(&_cond);
    }

    // 唤醒所有线程
    void Broadcast() {
        pthread_cond_broadcast(&_cond);
    }

private:
    pthread_cond_t _cond;
};

// 阻塞队列类(模板实现,支持任意数据类型)
template <typename T>
class BlockQueue {
public:
    explicit BlockQueue(int cap = 10) : _cap(cap) {}

    // 生产者:入队(生产数据)
    void Enqueue(const T& data) {
        LockGuard lock(_mutex);  // 自动加锁
        // 缓冲区满时,阻塞等待(用while避免虚假唤醒)
        while (_queue.size() >= _cap) {
            cout << "缓冲区满,生产者阻塞..." << endl;
            _full_cond.Wait(_mutex);  // 释放锁并阻塞
        }
        // 生产数据
        _queue.push(data);
        cout << "生产者生产数据:" << data << ",当前缓冲区大小:" << _queue.size() << endl;
        // 唤醒消费者(有新数据了)
        _empty_cond.Signal();
    }

    // 消费者:出队(消费数据)
    T Dequeue() {
        LockGuard lock(_mutex);  // 自动加锁
        // 缓冲区空时,阻塞等待
        while (_queue.empty()) {
            cout << "缓冲区空,消费者阻塞..." << endl;
            _empty_cond.Wait(_mutex);  // 释放锁并阻塞
        }
        // 消费数据
        T data = _queue.front();
        _queue.pop();
        cout << "消费者消费数据:" << data << ",当前缓冲区大小:" << _queue.size() << endl;
        // 唤醒生产者(有空闲位置了)
        _full_cond.Signal();
        return data;
    }

private:
    queue<T> _queue;        // 共享缓冲区
    int _cap;               // 缓冲区最大容量
    Mutex _mutex;           // 保护缓冲区的互斥锁
    Cond _full_cond;        // 缓冲区满的条件变量(生产者等待)
    Cond _empty_cond;       // 缓冲区空的条件变量(消费者等待)
};
测试代码(main.cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "BlockQueue.hpp"

BlockQueue<int> bq(5);  // 缓冲区容量为5

// 生产者线程函数
void* Producer(void* arg) {
    int id = *(int*)arg;
    for (int i = 0; i < 10; ++i) {
        int data = id * 100 + i;  // 生成唯一数据(区分不同生产者)
        bq.Enqueue(data);
        sleep(1);  // 模拟生产耗时
    }
    return nullptr;
}

// 消费者线程函数
void* Consumer(void* arg) {
    int id = *(int*)arg;
    while (1) {
        int data = bq.Dequeue();
        sleep(2);  // 模拟消费耗时
    }
    return nullptr;
}

int main() {
    pthread_t prod1, prod2, cons1, cons2;
    int id1 = 1, id2 = 2, id3 = 3, id4 = 4;

    // 创建2个生产者、2个消费者
    pthread_create(&prod1, nullptr, Producer, &id1);
    pthread_create(&prod2, nullptr, Producer, &id2);
    pthread_create(&cons1, nullptr, Consumer, &id3);
    pthread_create(&cons2, nullptr, Consumer, &id4);

    // 等待线程结束
    pthread_join(prod1, nullptr);
    pthread_join(prod2, nullptr);
    pthread_join(cons1, nullptr);
    pthread_join(cons2, nullptr);

    return 0;
}

运行结果分析

  • 当缓冲区满(容量5)时,生产者会阻塞,直到消费者消费数据后唤醒;
  • 当缓冲区空时,消费者会阻塞,直到生产者生产数据后唤醒;
  • 无数据错乱或线程永久阻塞的情况,实现了“生产-消费”的有序协调。

三、死锁:多线程编程的“陷阱”与避免

死锁是多线程编程中最危险的问题之一——多个线程互相持有对方需要的资源,且都不释放,导致永久阻塞。

1. 死锁的产生条件

根据操作系统理论,死锁的产生需同时满足以下4个条件,缺一不可:

  1. 互斥条件:资源只能被一个线程占用(如互斥锁);
  2. 持有并等待:线程持有一个资源后,又等待其他资源;
  3. 不可剥夺条件:线程已持有的资源不能被强制剥夺;
  4. 循环等待条件:多个线程形成资源等待循环(如线程A等线程B的资源,线程B等线程A的资源)。

2. 死锁案例:错误的锁获取顺序

假设两个线程需要同时获取两把锁(lockAlockB),若线程1先获取lockA再等lockB,线程2先获取lockB再等lockA,就会形成死锁:

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

pthread_mutex_t lockA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lockB = PTHREAD_MUTEX_INITIALIZER;

// 线程1:先锁A,再锁B
void* Thread1(void* arg) {
    pthread_mutex_lock(&lockA);
    cout << "线程1:持有lockA,等待lockB..." << endl;
    sleep(1);  // 让线程2有机会获取lockB
    pthread_mutex_lock(&lockB);  // 此时线程2已持有lockB,死锁!
    // 后续操作(不会执行)
    pthread_mutex_unlock(&lockB);
    pthread_mutex_unlock(&lockA);
    return nullptr;
}

// 线程2:先锁B,再锁A
void* Thread2(void* arg) {
    pthread_mutex_lock(&lockB);
    cout << "线程2:持有lockB,等待lockA..." << endl;
    sleep(1);  // 让线程1有机会获取lockA
    pthread_mutex_lock(&lockA);  // 此时线程1已持有lockA,死锁!
    // 后续操作(不会执行)
    pthread_mutex_unlock(&lockA);
    pthread_mutex_unlock(&lockB);
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, Thread1, nullptr);
    pthread_create(&t2, nullptr, Thread2, nullptr);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    return 0;
}

运行结果
线程1和线程2会永久阻塞,程序无法退出。

3. 死锁避免策略

只要破坏死锁的4个条件中的任意一个,就能避免死锁。工程中常用以下3种策略:

(1)固定锁的获取顺序

破坏“循环等待条件”:所有线程按统一的顺序获取锁。例如,规定“必须先获取lockA,再获取lockB”,修改上述案例:

// 线程2修改:先锁A,再锁B(与线程1顺序一致)
void* Thread2(void* arg) {
    pthread_mutex_lock(&lockA);  // 先获取lockA
    cout << "线程2:持有lockA,等待lockB..." << endl;
    sleep(1);
    pthread_mutex_lock(&lockB);  // 再获取lockB
    // 后续操作
    cout << "线程2:获取所有锁,执行操作" << endl;
    pthread_mutex_unlock(&lockB);
    pthread_mutex_unlock(&lockA);
    return nullptr;
}

此时线程1和线程2会按顺序获取锁,不会形成循环等待。

(2)一次性获取所有资源

破坏“持有并等待条件”:线程在执行前一次性获取所有需要的资源,若获取失败则释放已持有资源,重试。例如:

// 一次性获取lockA和lockB
int AcquireAllLocks() {
    // 非阻塞尝试获取lockA
    if (pthread_mutex_trylock(&lockA) != 0) {
        return -1;
    }
    // 非阻塞尝试获取lockB
    if (pthread_mutex_trylock(&lockB) != 0) {
        pthread_mutex_unlock(&lockA);  // 释放已获取的lockA
        return -1;
    }
    return 0;  // 所有锁获取成功
}

// 线程函数:重试直到获取所有锁
void* ThreadFunc(void* arg) {
    int id = *(int*)arg;
    while (AcquireAllLocks() != 0) {
        usleep(1000);  // 短暂等待后重试,避免CPU空耗
    }
    // 执行操作
    cout << "线程" << id << ":获取所有锁,执行操作" << endl;
    // 释放所有锁
    pthread_mutex_unlock(&lockB);
    pthread_mutex_unlock(&lockA);
    return nullptr;
}
(3)给锁添加超时时间

破坏“不可剥夺条件”:线程获取锁时设置超时时间,若超时未获取则释放已持有资源,重试。例如,使用pthread_mutex_timedlock接口:

#include <time.h>

void* ThreadFunc(void* arg) {
    int id = *(int*)arg;
    struct timespec ts;
    while (1) {
        // 设置超时时间:当前时间+1秒
        clock_gettime(CLOCK_REALTIME, &ts);
        ts.tv_sec += 1;

        // 尝试获取lockA(超时1秒)
        if (pthread_mutex_timedlock(&lockA, &ts) != 0) {
            cout << "线程" << id << ":获取lockA超时,重试..." << endl;
            continue;
        }

        // 尝试获取lockB(超时1秒)
        clock_gettime(CLOCK_REALTIME, &ts);
        ts.tv_sec += 1;
        if (pthread_mutex_timedlock(&lockB, &ts) != 0) {
            cout << "线程" << id << ":获取lockB超时,释放lockA重试..." << endl;
            pthread_mutex_unlock(&lockA);  // 释放lockA
            continue;
        }

        // 执行操作
        cout << "线程" << id << ":获取所有锁,执行操作" << endl;
        pthread_mutex_unlock(&lockB);
        pthread_mutex_unlock(&lockA);
        break;
    }
    return nullptr;
}

四、总结

线程同步与互斥是Linux多线程编程的核心,其目标是解决“数据安全”和“顺序可控”两大问题:

  1. 互斥:通过互斥锁(Mutex)保证临界区的原子访问,避免数据竞争;

    • 关键是“锁的正确使用”:成对加解锁、用RAII封装避免遗漏解锁;
    • 根据临界区执行时间选择互斥锁或自旋锁。
  2. 同步:通过条件变量(Condition Variable)协调线程执行顺序,满足逻辑依赖;

    • 关键是“处理虚假唤醒”:用while循环判断条件,而非if
    • 经典应用是生产者-消费者模型,需理解“321原则”。
  3. 死锁避免:破坏死锁的4个条件之一即可,常用策略包括固定锁顺序、一次性获取资源、添加超时时间。

掌握这些技术后,就能编写出高效、稳定的多线程程序,应对Linux服务器开发、高并发处理等场景的需求。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值