欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

线程同步与互斥

程序员文章站 2022-05-05 10:29:44
...

同步互斥概念

互斥:
  互斥就是指某一资源同时只能允许一个访问者对其进行访问,具有唯一性和排他性,但是互斥无法限制访问者对资源的访问顺序,即访问是无序的。
  对于线程来说,互斥就是说两个线程之间不可以同时运行,他们之间会相互排斥,必须等一个线程运行完毕之后,另一个才能运行。
同步:
  同步是指在互斥的基础上(大多数情况),通过其他机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所写入资源的情况必定是互斥的,少数情况可以是指允许多个访问者同时访问资源。
  对于线程来说,同步也是不能同时运行,但是它必须按照某种次序来运行相应。也就是按照一定的顺序运行线程,这种先后次序依赖于要完成的特定的任务。显然,同步是一种更复杂的互斥,互斥是一种特殊的同步。

线程间的同步互斥

mutex(互斥量)

  • 多个线程并发的操作共享变量,会带来一些问题
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

int g_count = 0;

void* ThreadEntry1(void* arg){
    (void)arg;
    while(1){
        ++g_count;
        sleep(1);
    }
    return NULL;
}

void* ThreadEntry2(void* arg){
    (void)arg;
    while(1){
        printf("g_count = %d\n", g_count);
        sleep(1);
    }
    return NULL;
}

int main(){
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, ThreadEntry1, p);
    pthread_create(&tid2, NULL, ThreadEntry2, p);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

一个线程要把某个全局变量增加1另一个线程读出,这个操作并不是原子操作,至少需要三条指令才能完成:

  1. 将全局变量从内存中加载到寄存器
  2. 寄存器的值加1
  3. 将寄存器的值写回内存

由于存在线程安全问题,当线程一进行加一操作时,可能还未完成加一操作线程而便来读取,这将会导致与预期结果不同。

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

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

此时引入互斥量的概念:
线程同步与互斥

(1)创建锁资源:
pthread_mutex_t mutex;
  创建锁资源就像创建变量一样,锁的类型是pthread_mutex_t。因为线程是共享数据区和堆区的,所以我们可以创建全局或静态的锁变量或者在堆上创建,这样就可以使得所有线程都可以看见锁,所以说实现线程间通信是非常简单的。
(2)初始化锁:
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);
如果创建的是全局或静态的锁的话,可以用宏PTHREAD_MUTEX_INITIALIZER初始化。也可以用函数初始化。
attr:变量表示锁的属性。为NULL的话就相当于用宏初始化。
返回值:成功返回0,失败返回错误码。
(3)销毁锁:
int pthread_mutex_destroy(pthread_mutex_t* mutex);
成功返回0,失败返回错误码。
注意:

  • 使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

(4)加锁:
int pthread_mutex_lock(pthread_mutex_t* mutex);
返回值:成功返回0,失败返回错误码。
(5)解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误码。
  一个线程可以调用pthread_mutex_lock获得锁,如果这时另一个线程先获得锁,则当前线程需要挂起等待,直到另一个线程调用pthread_mutex_unlock解锁,当前线程被唤醒,才能获得锁,并继续执行。可以看到,互斥锁就像轻量级的二元信号量一样,只能用在线程间,而信号量既可以用在线程间,也可以用在进程间。

死锁

死锁危害:线程一直等待,可能导致整个服务器崩溃
死锁的两个常见场景:

  • 一个线程获取到锁之后,又尝试获取锁,就会出现死锁
  • 两个线程A和B,线程A获取了锁1,线程B获取了锁2。然后A尝试获取锁2,B尝试获取锁1。这个时候双方都无法拿到对方的锁,并且会在获取锁的函数中阻塞等待
    如果线程数和锁的数目更多了,就会使死锁问题更容易出现,问题场景更复杂

对于这种需要获取多个锁的场景,规定所有的线程都按照固定的顺序来获取锁,能够一定程度上避免死锁。

避免死锁:临界区代码”短平快”

  • :临界区代码简短明了
  • :临界区代码逻辑清晰,没有复杂的函数调用,尤其是尽量不要申请其他互斥资源
  • :临界区代码执行速度快

线程安全与可重入

可重入函数:在多个执行流中被同时调用不会存在逻辑问题
线程安全函数:在多线程中被同时调用不会存在逻辑问题

  • 可重入函数一般情况下都是线程安全的
  • 线程安全函数不一定是可重入的
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>

pthread_mutex_t g_lock;
int g_count = 0;

//Func函数线程安全,不可重入
void Func(){
    pthread_mutex_lock(&g_lock);
    printf("lock!\n");
    ++g_count;
    sleep(3);
    printf("unlock!\n");
    pthread_mutex_unlock(&g_lock);
}

void* ThreadEntry(void* arg){
    (void)arg;
    while(1){
        Func();
    }
    return NULL;
}

void MyHandler(int sig){
    (void)sig;
    Func();
}

int main(){
    signal(SIGINT, MyHandler);
    pthread_mutex_init(&g_lock, NULL);
    ThreadEntry(&g_count);
    pthread_mutex_destroy(&g_lock);
    return 0;
}

  在lock!打印完后按下Ctrl+c,这时进程收到此信号就会进入到信号处理函数中,也尝试获取锁,信号处理函数就会阻塞在加锁函数中。同时我们知道信号处理函数如果不执行结束,操作系统是不会切换回原有的执行逻辑的,这就意味着主线程再也没有机会释放锁了。因此对于这个场景下, Func函数是线程安全的,但是不可重入。

条件变量

为什么要有条件变量:
  线程间同步还有这样一种情况,线程A需要等待某个条件成立才能继续向下执行,现在这个条件不成立,线程A就被阻塞等待,而线程B在执行的过程中使这个条件成立了,就唤醒线程A继续执行。在pthread库中通过条件变量(Condition Variable)来阻塞等待一个条件,或唤醒等待这个条件的线程。

条件变量函数:
(1)条件变量的创建:
int pthread_cond_t cond;
  条件变量的创建和普通变量一样,为了能够实现共享条件变量,所以将条件变量创建为全局或静态变量,或者在堆上创建。

(2)条件变量的初始化:
条件变量和mutex是非常相似的,如果创建成全局或静态的,则可以使用宏来初始化:
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
还可以使用函数来初始化:
int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* attr);
返回值:成功返回0,失败返回错误码。
cond:要初始化的条件变量
attr:NULL

(3)条件变量的销毁:
int pthread_cond_destroy(pthread_cond_t* cond);
返回值:成功返回0,失败返回错误码。

(4)等待条件满足:
int pthread_cond_wait(pthread_cond_t* cond,pthread_mutex_t* mutex);
返回值:成功返回0,失败返回错误码。
cond:要在这个条件变量上等待
mutex:互斥量
  可以预想到,在wait中肯定是要释放锁的。如果解锁和等待不是原子操作的话,那么在解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过(竞态条件),导致永远阻塞在wait中。所以解锁和等待必须是一个原子操作。
线程同步与互斥

(5)唤醒等待:
int pthread_cond_broadcast(pthread_cond_t *cond);
返回值:成功返回0,失败返回错误码。
broadcast可以唤醒所有在这个cond上等待的线程。
int pthread_cond_signal(pthread_cond_t *cond);
返回值:成功返回0,失败返回错误码。
singnal可以唤醒某个在条件变量cond上等待的线程。在signal里面肯定是要再次获取锁资源的。

如:蓝球运动员的传球投篮,只有球传到运动员手中,他才可以去投篮

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

pthread_cond_t cond;
pthread_mutex_t lock;

void* Pass(void* arg){
    (void)arg;
    while(1){
        printf("传球!\n");
        pthread_cond_signal(&cond);
        usleep(789123);
    }
    return NULL;
}

void* Shot(void* arg){
    (void)arg;
    while(1){
        pthread_cond_wait(&cond, &lock);
        printf("投篮!\n");
        usleep(123456);
    }
    return NULL;
}

int main(){
    //模拟蓝球训练场景
    //球员1,球员2
    //球员1负责传球,球员2负责投篮
    pthread_t tid1, tid2;
    pthread_cond_init(&cond, NULL);
    pthread_mutex_init(&lock, NULL);
    pthread_create(&tid1, NULL, Pass, NULL);
    pthread_create(&tid2, NULL, Shot, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);
    return 0;
}

读写锁

  在一些程序中存在读者写者问题,也就是说,对某些资源的访问会存在两种可能的情况:一种是访问必须是排它的,就是独占的意思,这称作写操作;另一种情况就是访问方式可以是共享的,就是说可以有多个线程同时去访问某个资源,这种就称作读操作。这个问题模型是从对文件的读写操作中引申出来的。
  通常而言,在读的过程中,往往伴随着查找操作,中间耗时很长,给这段代码加锁的话会极大的降低我们的效率。针对这种多读少写的情况,我们通常采用读写锁。 (写独占,读共享,写锁优先级高
  读写锁是本质上一种特殊的自旋锁,他把对共享资源的访问者划分成读者和写者,读者只对共享资源进行访问,写者只对共享资源进行写操作,一个读写锁同时只能有一个写者或多个读者,但是不能同时既有写者又有读者。 读写锁比起mutex具有更高的适用性,具有更高的并行性,可以有多个线程同时占用读模式的读写锁,但是只能有一个线程占用写模式的读写锁。
  自旋锁的作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远高于互斥锁。虽然它的效率比互斥锁高,但是它也有些不足之处:

  1. 自旋锁一直占用CPU,他在未获得锁的情况下,一直运行,如果不能在很短的时间内获得锁,就会大大消耗CPU资源
  2. 在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。因此我们要慎重使用自旋锁,自旋锁只有在内核可抢占式或SMP的情况下才真正需要,在单CPU且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况下。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

pthread_rwlock_t rwlock;
int count = 0;

void* writer(void* arg){
    (void)arg;
    while(1){
        pthread_rwlock_wrlock(&rwlock);
        ++count;
        printf("write:%d\n", count);
        pthread_rwlock_unlock(&rwlock);
        sleep(1);
    }
    return NULL;
}

void* reader(void* arg){
    (void)arg;
    while(1){
        pthread_rwlock_rdlock(&rwlock);
        printf("read:%d\n", count);
        pthread_rwlock_unlock(&rwlock);
        sleep(1);
    }
    return NULL;
}

int main(){
    pthread_t tid[8];
    pthread_rwlock_init(&rwlock, NULL);
    int i = 0;
    for(; i < 3; ++i){
        pthread_create(&tid[i], NULL, writer, NULL);
    }
    for(i = 0; i < 5; ++i){
        pthread_create(&tid[i], NULL, reader, NULL);
    }
    for(i = 0; i < 8; ++i){
        pthread_join(tid[i], NULL);
    }
    pthread_rwlock_destroy(&rwlock);
    return 0;
}
相关标签: 线程同步与互斥