《线程同步机制全解析:锁、条件变量与信号量》

线程同步(Thread Synchronization)


一句话定义:

线程同步(Thread Synchronization) 就是多个线程之间有秩序地访问共享资源,避免“抢”数据、乱套、程序崩掉。

多个线程同时运行的时候,如果它们访问同一份数据(共享资源),可能会出问题


1. 竞态条件和锁

竞态(Race Condition)是什么?

“竞态”又叫竞态条件(Race Condition),是并发/多线程编程里最常见、也最危险的问题之一。

多个线程或进程“同时”访问/修改同一个资源(变量、文件、内存)**时,
如果操作之间**没有合适的同步机制(加锁)
,就会出现执行顺序不确定的现象,
最终导致程序结果错误或行为异常

这就叫:竞态


举个例子:

假设有两个线程,同时对一个变量 numnum++

// 初始值 num = 0
Thread A: 读取 num -> 0
Thread B: 读取 num -> 0

Thread A: num = 0 + 1 = 1
Thread B: num = 0 + 1 = 1  ❌

最终结果:num = 1,但应该是 2

本来 num 应该加两次变成 2,结果因为两个线程同时读了 0,最后被覆盖了,变成了 1,这就是一个典型的竞态


遇到这种情况怎么办?别急,这正是我们接下来要介绍的重点——的使用

为了帮助大家更好地理解,我会结合几个实际例子,带你认识几种常用的锁类型,搞清楚它们分别适合哪些场景,用起来又有什么区别。


2.互斥锁

1. 互斥锁(Mutex)简介


在多线程编程中,互斥锁(Mutex,Mutual Exclusion) 是最基础也是最常用的一种线程同步机制。

它的作用是:

一次只允许一个线程访问某一段关键代码,其他线程必须等待,避免共享资源被同时修改,从而防止竞态条件(Race Condition)

就像排队打水,一个水龙头只能一个人用,别人必须等前面的人用完。

2.代码示例


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define THREAD_COUNT 20000
static pthread_mutex_t counter_mutex=PTHREAD_MUTEX_INITIALIZER;

//创建多个线程
void*add(void * arg)
{
    //转化传入参数
    int *p=(int*)arg;
    //在累加之前,上一个锁,保证同一时间只有一个线程对其累加
    pthread_mutex_lock(&counter_mutex);
    (*p)++;
    //累加完之后释放锁
    pthread_mutex_unlock(&counter_mutex);
    return (void *) 0;
}
int main(int argc, char const *argv[])
{
    pthread_t pid[THREAD_COUNT];
    int num=0;
    for(size_t i=0;i<THREAD_COUNT;i++)
    {
        //创建的线程功能是给传入的参数累加1
        pthread_create(pid+i,NULL,add,&num);

    }
    //等待所有线程执行完成
    for(size_t i=0;i<THREAD_COUNT;i++)
    {
        pthread_join(pid[i],NULL);

    }
    //打印最终累加结果
    printf("累加结果:%d\n", num);


    return 0;
}

输出结果如下:
在这里插入图片描述


3.读写锁


1.读写锁(Read-Write Lock)简介

在多线程中,有些共享资源读取操作远多于写入操作,如果每次都用互斥锁来限制访问,就显得太保守了。

这时候,读写锁就登场了。

多个线程可以同时读共享资源
写线程必须独占,不能同时有读或写线程

简单理解就是:

  • 多个“读者”可以一起看书 📖
  • 但只要有人要写字 ✍️,所有人都必须放下书等他写完!

2.代码示例

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

//static pthread_rwlock_t rwlock=__PTHREAD_RWLOCK_INITIALIZER;
pthread_rwlock_t rwlock;
int shared_data = 0;

void*lock_read(void*arg)
{
    //读写锁的读是可以由多个线程统一读取的
    //获取读锁
    pthread_rwlock_rdlock(&rwlock);
    printf("当前是%s,shared_data为:%d\n",(char*)arg,shared_data);
    pthread_rwlock_unlock(&rwlock);
}

void*lock_write(void*arg)
{
    //给多个线程写入添加写锁
    //同一时间只有一个线程获取写锁,会造成两个线程顺序执行
    pthread_rwlock_wrlock(&rwlock);
    int tmp=shared_data+1;
    sleep(1);
    shared_data=tmp;
    printf("当前是%s,shared_data为:%d\n",(char*)arg,shared_data);
    //写入完成之后释放写锁
    pthread_rwlock_unlock(&rwlock);
}

int main(int argc, char const *argv[])
{
    //显示初始化读写锁
    pthread_rwlock_init(&rwlock,NULL);
    pthread_t write1,write2,reader1, reader2, reader3, reader4, reader5, reader6;
    //创建两个写线程
    pthread_create(&write1,NULL,lock_write,"write1");
    pthread_create(&write2,NULL,lock_write,"write2");
    //休眠等待
    sleep(3);
    pthread_create(&reader1,NULL,lock_read,"reader1");
    pthread_create(&reader2,NULL,lock_read,"reader2");
    pthread_create(&reader3,NULL,lock_read,"reader3");
    pthread_create(&reader4,NULL,lock_read,"reader4");
    pthread_create(&reader5,NULL,lock_read,"reader5");
    pthread_create(&reader6,NULL,lock_read,"reader6");
    //主线程等待创建的子线程运行完成
    pthread_join(write1, NULL);
    pthread_join(write2, NULL);
    pthread_join(reader1, NULL);
    pthread_join(reader2, NULL);
    pthread_join(reader3, NULL);
    pthread_join(reader4, NULL);
    pthread_join(reader5, NULL);
    pthread_join(reader6, NULL);
    //销毁读写锁
    pthread_rwlock_destroy(&rwlock);
    return 0;
}

输出结果如下图所示:

在这里插入图片描述

我觉得读写锁是一种“变身”

比如创建一个 a,它可以变成 bc的锁,也就是读模式写模式,最后释放锁将bc还原成原来的锁也就是a

我们可以这样理解:

  • a 是一个 锁的变量(如 rwlock
  • pthread_rwlock_rdlock(&a):“读模式”=b
  • pthread_rwlock_wrlock(&a):“写模式”=c
  • pthread_rwlock_unlock(&a):“释放”=(b,c)-> a

3.写线程“饿死”了?


在上面代码中我们通过手动设置睡眠让读写进程顺利进行,如果我们没有加入睡眠会出现这种情况
在这里插入图片描述

进程顺序变化不一默认情况下,读写锁是读优先的,所以线程执行的顺序可能并不固定。
这时候就有个问题了
假如有个线程一直想写点东西,但别的线程一个接一个地抢着来,它就只能一边干瞪眼一边排队……
写不进去、插不进队,这种被“饿”到的情况,就是“写饥饿”。

这时候我们可以请出:pthread_rwlockattr_t —— 它是读写锁的属性结构体
可以用来配置锁的行为策略,比如:改成“写优先”或公平策略,从而缓解或避免“写饥饿”问题。

	pthread_rwlockattr_t attr;
    pthread_rwlockattr_init(&attr);
    pthread_rwlockattr_setkind_np(&attr, PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
    pthread_rwlock_init(&rwlock,&attr);
    pthread_rwlockattr_destroy(&attr);

在原基础代码上,添加代码,就可以让写的权限大于读,就能解决写饥饿现象。

pthread_rwlockattr_t 就像是“配置权限的一张表”,你在上面勾选(比如写优先),然后拿这张表去创建锁(初始化锁),一旦锁创建好了,这张表的作用就结束了,就可以安全地删掉(destroy)。

4.自旋锁

1.自旋锁简介(spinlock

什么是自旋锁?

自旋锁(Spinlock) 是一种轻量级锁机制,用于线程同步。当一个线程尝试获取锁时:

  • 如果锁已经被其他线程占用,它不会睡眠阻塞
  • 而是会不断“自旋”等待锁的释放(即在原地不停地循环尝试获取锁)。

这就像是:

“我就在门口等,门一开我就冲进去,而不是去睡觉等通知。”


使用场景
  • 适用于锁占用时间非常短的临界区。
  • 如果锁占用时间长,自旋会导致 CPU 空转浪费资源。

自旋锁主要用于内核模块或驱动程序中,避免上下文切换的开销。不能在用户空间使用,这里就不多介绍了。

5.补充!!!

1.条件变量

条件变量(condition variable)和互斥锁(mutex)常常是一起使用的,它们是多线程编程中实现线程同步和通信的重要工具

互斥锁用来保护“访问共享资源”的原子性,条件变量用来“等待某个条件的发生”

只用锁,线程只能“傻等”(轮询),容易浪费资源;
条件变量让线程可以“聪明地等”,别人来通知你再干活,效率高又优雅。


代码示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define BUFFER_SIZE 5

int buffer[BUFFER_SIZE];
int count = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 初始化条件变量
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//期望功能是读或写的一方 一直进行读写操作 直到缓存读满或者写满 暂时释放锁

//写数据
void * producer(void *arg)
{
    int item=1;
    pthread_mutex_lock(&mutex);
    while (1)
    {
        //获取锁
        
        //如果缓存区写满,使用条件变量暂停当前线程
        if(count==BUFFER_SIZE)
        {
            //暂停线程
            pthread_cond_wait(&cond,&mutex);        
        }
        buffer[count++]=item++;
        printf("我发送了一个数字%d\n",buffer[count-1]);
        //唤醒消费者
        pthread_cond_signal(&cond);
    }
    //解锁
    pthread_mutex_unlock(&mutex);
}
//读数据
void *consumer(void*arg)
{
    pthread_mutex_lock(&mutex);
    while (1)
    {
        if(count==0)
        {
            //无数据可读
            //暂停线程
            pthread_cond_wait(&cond,&mutex);
        }
        printf("我收到的数字%d\n",buffer[--count]);
        pthread_cond_signal(&cond);
    }
    pthread_mutex_unlock(&mutex);

}

int main(int argc, char const *argv[])
{
    //创建两个线程,一个从buf中写,一个从buf中读
    pthread_t producer_thread,consumer_thread;
    pthread_create(&producer_thread,NULL,producer,NULL);
    pthread_create(&consumer_thread,NULL,consumer,NULL);

    //主线程等待两个线程完成
    pthread_join(producer_thread,NULL);
    pthread_join(consumer_thread,NULL);

    return 0;
}

输出结果如下:

在这里插入图片描述


2.信号量

锁是用来保证线程安全访问共享资源的;信号量决定了线程该不该继续干活。

信号量是线程之间沟通的信号,告诉线程:干活,还是等一等。

有名信号量 vs 无名信号量 区别:
项目有名信号量 (sem_open)无名信号量 (sem_init)
是否有名字✅ 有名字,系统中全局可见❌ 没有名字,只在内存中可用
使用方式类似文件,通过 sem_open("/name", ...) 打开直接初始化 sem_t 结构体
是否需要共享内存否,自动在系统中维护是,若用于进程间通信需使用共享内存(如 mmap)
生命周期独立于进程,需显式 sem_unlink() 删除随变量生命周期自动销毁
适用场景多进程同步,跨程序同步线程同步或使用共享内存的进程同步

接下来对无名信号量介绍:


2.1无名信号量
1)作为二进制信号量用于线程间通信
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>

sem_t unnamed_sem;
int shard_num = 0;

void *plusOne(void *argv) {
    sem_wait(&unnamed_sem);
    int tmp = shard_num + 1;
    shard_num = tmp;
    sem_post(&unnamed_sem);
}
int main() {
    sem_init(&unnamed_sem, 0, 1);//第二个参数是0表示线程,1表示进程,第三个参数表示几个资源可用
    pthread_t tid[10000];
    for (int i = 0; i < 10000; i++) {
        pthread_create(tid + i, NULL, plusOne, NULL);
    }
    for (int i = 0; i < 10000; i++) {
        pthread_join(tid[i], NULL);
    }
    printf("shard_num is %d\n", shard_num);
    sem_destroy(&unnamed_sem);

    return 0;
}

疑问?
信号量也能实现互斥锁
那为什么还要专门用互斥锁?

虽然信号量能实现互斥锁功能,但互斥锁是专为互斥而设计的同步机制,在某些场景下更合适。

项目信号量(Semaphore)互斥锁(Mutex)
功能通用同步工具专门用于线程间互斥访问
初始值可以是任意非负整数通常只有锁定/释放两个状态
线程归属没有线程所有权概念有线程所有权,谁加锁谁解锁
解锁限制任意线程可以调用 post()只有加锁线程才能解锁(更安全)
使用复杂度稍复杂简洁直接,适合纯粹互斥
典型用法资源池、生产者消费者模型等临界区保护

什么时候该用信号量 vs 用互斥锁?
场景推荐使用
保护一个临界区,防止并发访问互斥锁(mutex)
资源池中有多个资源(比如 10 个)计数信号量
实现线程或进程之间的同步关系信号量

信号量是万能工具,互斥锁是专用工具。

如果你只是想实现“只能一个线程访问某段代码”的功能,互斥锁更合适更安全;如果你有更复杂的同步需求(比如资源计数、线程等待唤醒),用信号量。


2)作为二进制信号量用于进程间通信
“使用共享内存与命名信号量实现父子进程同步与数据共享”
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *shm_sem_name = "unnamed_sem_shm_sem";
    char *shm_value_name = "unnamed_sem_shm_value";
    // 创建内存共享对象
    int sem_fd = shm_open(shm_sem_name, O_CREAT | O_RDWR, 0666);
    int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);
    // 调整内存共享对象的大小
    ftruncate(sem_fd, sizeof(sem_t));
    ftruncate(value_fd, sizeof(int));
    // 将内存共享对象映射到共享内存区域
    sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, sem_fd, 0);
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, value_fd, 0);
    // 初始化信号量和共享变量的值
    sem_init(sem, 1, 1);
    *value = 0;
    int pid = fork();

    if (pid > 0)
    {
        sem_wait(sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(sem);
        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        printf("this is father, child finished\n");
        printf("the final value is %d\n", *value);
    }
    else if (pid == 0)
    {
        sem_wait(sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(sem);
    }
    else
    {
        perror("fork");
    }
    // 父进程执行到这里,子进程已执行完毕,可以销毁信号量
    if (pid > 0)
    {
        if (sem_destroy(sem) == -1)
        {
            perror("sem_destory");
        }
    }
    // 无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
    if (munmap(sem, sizeof(sem)) == -1)
    {
        perror("munmap sem");
    }

    if (munmap(value, sizeof(int)) == -1)
    {
        perror("munmap value");
    }

    if (close(sem_fd) == -1)
    {
        perror("close sem");
    }

    if (close(value_fd) == -1)
    {
        perror("close value");
    }

    // 如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
    // shm_unlink只能调用一次,这里在父进程中调用shm_unlink
    if (pid > 0)
    {
        if (shm_unlink(shm_sem_name) == -1)
        {
            perror("father shm_unlink shm_sem_name");
        }

        if (shm_unlink(shm_value_name) == -1)
        {
            perror("father shm_unlink shm_value_name");
        }
    }

    return 0;

使用方法是类似的,输出结果如下:

在这里插入图片描述


3)作为计数信号量用于线程间通信
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>

sem_t *full;
sem_t *empty;
int shard_num;
int rand_num()
{
    srand(time(NULL));
    return rand();
}
void *producer(void *argv)
{
    for (int i = 0; i < 5; i++)
    {
        sem_wait(empty);
        printf("\n==========> 第 %d 轮数据传输 <=========\n\n", i + 1);
        sleep(1);
        shard_num = rand_num();
        printf("producer has sent data\n");
        sem_post(full);
    }
}
void *consumer(void *argv)
{
    for (int i = 0; i < 5; i++)
    {
        sem_wait(full);
        printf("consumer has read data\n");
        printf("the shard_num is %d\n", shard_num);
        sleep(1);
        sem_post(empty);
    }
}
int main()
{
    full = malloc(sizeof(sem_t));
    empty = malloc(sizeof(sem_t));

    sem_init(empty, 0, 1);
    sem_init(full, 0, 0);

    pthread_t producer_id, consumer_id;
    pthread_create(&producer_id, NULL, producer, NULL);
    pthread_create(&consumer_id, NULL, consumer, NULL);
    pthread_join(producer_id, NULL);
    pthread_join(consumer_id, NULL);
    sem_destroy(empty);
    sem_destroy(full);
    return 0;
}

输出结果如下:
在这里插入图片描述



这里我对这段代码做简单讲解:

void *producer(void *argv)
{
    for (int i = 0; i < 5; i++)
    {
        sem_wait(empty);
        printf("\n==========> 第 %d 轮数据传输 <=========\n\n", i + 1);
        sleep(1);
        shard_num = rand_num();
        printf("producer has sent data\n");
        sem_post(full);
    }
}
void *consumer(void *argv)
{
    for (int i = 0; i < 5; i++)
    {
        sem_wait(full);
        printf("consumer has read data\n");
        printf("the shard_num is %d\n", shard_num);
        sleep(1);
        sem_post(empty);
    }
}

在前面我们定义了

empty为1,full为0

sem_init(empty, 0, 1);
sem_init(full, 0, 0);

sem_wait()可以理解为-1,sem_post()为+1,当小于0的时候阻塞

这里我们对empty和full都-1,发现full<0所以consumer()函数阻塞,也就是先发送,之后post让full+1

这时候full 为1,empyt为0,

这样我们就能交替运行了


4)作为计数信号量用于进程间通信
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(int argc, char const *argv[])
{
    char *shm_name="unnamed_sem_shm";
    int fd=shm_open(shm_name,O_CREAT|O_RDWR,0644);
    ftruncate(fd,sizeof(sem_t));
    //完成映射
    sem_t *sem= mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    //初始化信号量
    sem_init(sem,1,0);
    //创建父子进程
    pid_t pid=fork();
    if(pid<0)
    {perror("fork");}
    else if(pid==0)
    {
        //子进程
        sleep(1);
        printf("这是子进程\n");
        sem_post(sem);
    }
    else
    {
        //父进程
        sem_wait(sem);
        printf("这是父进程\n");
        waitpid(pid,NULL,0);

    } 
    if(pid>0)
    {
        if((sem_destroy(sem))==-1)
        {
            perror("sem_destroy");
        }
    }
    if(munmap(sem,sizeof(sem))==-1)
    {
        perror("munmap");
    }
    if(close(fd)==-1)
    {
        perror("close");
    }
    if(pid>0)
    {
        if(shm_unlink(shm_name)==-1)
        {
            perror("shm_unlink");
        }
    }
    return 0;
}

输出结果如下:

在这里插入图片描述


接下来对有名信号量介绍:


2.2有名信号量

有名信号量与无名信号量创建方式不填一样,但逻辑基本一致,这里只展示进程间的通讯

1)有名信号量用作二进制信号量
#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
    char *sem_name = "/named_sem";
    char *shm_name = "/named_sem_shm";
    // 初始化有名信号量
    sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 1);
    // 初始化内存共享对象
    int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
    // 调整内存共享对象的大小
    ftruncate(fd, sizeof(int));
    // 将内存共享对象映射到内存空间
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    // 初始化共享变量指针指向位置的值
    *value = 0;
    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork");
    }
    sem_wait(sem);
    int tmp = *value + 1;
    sleep(1);
    *value = tmp;
    sem_post(sem);
    // 每个进程都应该在使用完毕后关闭对信号量的连接
    sem_close(sem);
    if (pid > 0)
    {
        waitpid(pid, NULL, 0);
        printf("子进程执行结束,value = %d\n", *value);

        // 有名信号量的取消链接只能执行一次
        sem_unlink(sem_name);
    }
    // 父子进程都解除内存共享对象的映射,并关闭相应的文件描述符
    munmap(value, sizeof(int));
    close(fd);
    // 只有父进程应该释放内存共享对象
    if (pid > 0)
    {
        if (shm_unlink(shm_name) == -1)
        {
            perror("shm_unlink");
        }
    }
    return 0;
}

输出结果如下:

在这里插入图片描述

通过信号量避免了竞态条件


2)有名信号量用作计数信号量
#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *sem_name = "/named_sem";
    // 初始化有名信号量
    sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 0);
    pid_t pid = fork();
    if (pid > 0) {
        sem_wait(sem);
        printf("this is father\n");
        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        // 释放引用
        sem_close(sem);
        // 释放有名信号量
        if(sem_unlink(sem_name) == -1) {
            perror("sem_unlink");
        }
    } else if(pid == 0) {
        sleep(1);
        printf("this is son\n");
        sem_post(sem);

        // 释放引用
        sem_close(sem);
    } else
    {
        perror("fork");
    }
    return 0;
}

输出结果如下:
在这里插入图片描述

总结

本文围绕竞态现象,介绍了多种常见的线程同步机制,包括互斥锁、读写锁、自旋锁的使用方法和适用场景,同时补充了条件变量信号量的基本概念和用法。希望这篇文章能对你有所帮助,助你更好地学习和理解相关内容。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值