Linux系统编程(六)--线程互斥与同步

1 多线程互斥与同步

多线程互斥,同一时间只有一个线程访问数据。

  • 互斥锁 mutex
  • 读写锁 rwlock
  • 自旋锁 spinlock

多线程同步

执行有顺序。只有当我做完了某件事,你才能做另一件事。比如,只有你做完作业了,我才能给你批改。

两种方式实现:

  1. 你做完作业了,然后你打电话通知我,我再去帮你修改

  2. 我每隔一段时间打电话问你写完作业没,确认你写完了,我再去帮你修改

条件变量实现功能1。

2 互斥量

2.1 基本概念

为确保同一时间只有一个线程访问数据,在访问共享资源前需要对互斥量上锁。一旦对互斥量上锁后,任何其他试图再次对互斥量上锁的线程都会被阻塞,进入等待队列。

2.2 互斥量数据类型

数据类型:pthread_mutex_t,使用前必须进行初始化。两种初始化方法:

  1. 静态分配,设置为常量 PTHREAD_MUTEX_INITIALIZER.

  2. 使用函数 pthread_mutex_init 进行初始化,用此方法初始化,用完后需使用 pthread_mutex_destroy 回收。

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutext_t *mutex);

说明:

  1. restrict 关键字的含义:访问指针 mutex 指针的内容,唯一方法是使用 mutex 指针。这告诉编译器:除了 mutex 指针指向这个内存,再也没别的指针指向这里。

  2. pthread_mutexattr_t 类型,互斥量的属性。

2.3 互斥量的加锁解锁

// 用于加锁的两个函数
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);    //非阻塞

// 解锁只有下面这一种方法
int pthread_mutex_unlock(pthread_mutex_t *mutex);

使用 pthread_mutex_trylock,无论之前有没有上锁,立即返回,通过返回值判断是否成功:

  • 返回 0,上锁成功。

  • 返回 EBUSY,上锁失败。

lock 函数对 mutex 的操作是原子的,一次执行成功,要么一次执行失败。

实验:抢票问题,一共三张票,两人抢票

// buyticket.c
#include <unistd.h>
#include <stdio.h>
#include <pthread.h>

int tickets = 3;
// 使用静态初始化的方式初始化一把互斥锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* allen(void* arg) {
    int flag = 1;
    while (flag) {
        // 上锁
        pthread_mutex_lock(&lock);
        int t = tickets;
        usleep(1000 * 20);// 20ms
        if (t > 0) {
            printf("allen buy a ticket\n");
            --t;
            usleep(1000 * 20);// 20ms
            tickets = t;
        }
        else flag = 0;
        // 解锁
        pthread_mutex_unlock(&lock);
        usleep(1000 * 20);// 20ms
    }
    return NULL;
}

void* luffy(void* arg) {
    int flag = 1;
    while (flag) {
        // 上锁
        pthread_mutex_lock(&lock);
        int t = tickets;
        usleep(1000 * 20);
        if (t > 0) {
            printf("luffy buy a ticket\n");
            --t;
            usleep(1000 * 20);// 20ms
            tickets = t;
        }
        else flag = 0;
        // 解锁
        pthread_mutex_unlock(&lock);
        usleep(1000 * 20);// 20ms
    }
    return NULL;
}
int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, allen, NULL);
    pthread_create(&tid2, NULL, luffy, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

编译运行

$ gcc buyticket.c -o buyticket -lpthread
$ ./buyticket 
luffy buy a ticket
allen buy a ticket
luffy buy a ticket

3 读写锁rwlock

3.1 读写锁三种状态:

读加锁状态、写加锁状态和不加锁状态。

  1. 锁处于写加锁状态,其它任何人都只能等待

  2. 锁处于读加锁状态,这时:

    • 在没有人加写锁的情况下,其它线程可以继续加读模式的锁。
    • 其它任何加写锁的线程只能等待,同时加写锁后,加读锁只能等待。(防止写线程处于饥饿状态,即长时间不能加写模式锁)

3.2 读写锁的数据类型

数据类型: pthread_rwlock_t。有两种方法初始化:

  1. 使用宏 PTHREAD_RWLOCK_INITIALIZER 静态初始化读写锁。

  2. 使用 pthread_rwlock_init 函数动态初始化锁。

// 初始化读写锁的函数
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);

// 回收读写锁  
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

attr:读写锁属性,读写锁用完使用 destroy 函数回收。

3.3 读写锁的加锁解锁

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);    //读模式加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);    //写模式加锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);    //解锁

上面所有函数返回0表示成功,否则失败。

非阻塞版本:

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

无论加锁是否成功,立即返回,成功返回 0,失败返回 EBUSY。

实验

程序 trainticket 中,有 100 个线程,其中 90 个线程是查余票数量的,只有 10 个线程抢票,每个线程一次买 10 张票。初始状态下一共有 1000 张票。因此执行完毕后,还会剩下 900 张票。

程序 trainticket 在运行的时候需要传入参数,即 ./trainticket <0|1|2>:

  • 参数 0:表示不加任何锁
  • 参数 1:表示使用读写锁
  • 参数 2:表示使用互斥量
// trainticket.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>

struct Ticket {
    int remain; // 余票数,初始化为 1000
    pthread_rwlock_t rwlock; // 读写锁
    pthread_mutex_t mlock; // 互斥锁,主要是为了和读写锁进行对比
}ticket;

// 通过命令行传参数来取得这个值,用来控制到底使用哪一种锁
// 0:不加锁 1:加读写锁 2:加互斥锁
int lock = 0;

// 查票线程
void* query(void* arg) {
    int name = (int)arg;
    sleep(rand() % 5 + 1);
    if (lock == 1)
        pthread_rwlock_rdlock(&ticket.rwlock); // 读模式加锁
    else if (lock == 2)
        pthread_mutex_lock(&ticket.mlock);

    int remain = ticket.remain;
    sleep(1);
    printf("%03d query: %d\n", name, remain);

    if (lock == 1)
        pthread_rwlock_unlock(&ticket.rwlock);
    else if (lock == 2)
        pthread_mutex_unlock(&ticket.mlock);

    return NULL;
}

// 抢票线程
void* buy(void* arg) {
    int name = (int)arg;

    if (lock == 1)
        pthread_rwlock_wrlock(&ticket.rwlock); // 写模式加锁
    else if (lock == 2)
        pthread_mutex_lock(&ticket.mlock);

    int remain = ticket.remain;
    remain -= 10; // 一次买 10 张票
    sleep(1);
    ticket.remain = remain;
    printf("%03d buy 10 tickets\n", name);

    if (lock == 1)
        pthread_rwlock_unlock(&ticket.rwlock);
    else if (lock == 2)
        pthread_mutex_unlock(&ticket.mlock);

    sleep(rand() % 5 + 2);

    return NULL;
}

int main(int argc, char* argv[]) {
    lock = 0;
    if (argc >= 2) lock = atoi(argv[1]);

    int names[100];
    pthread_t tid[100]; // 之前写成了 pthread_t tid[10],感谢 @juruiyuan111 指出错误
    int i;

    for (i = 0; i < 100; ++i) names[i] = i;

    ticket.remain = 1000;

    printf("remain ticket = %d\n", ticket.remain);

    pthread_rwlock_init(&ticket.rwlock, NULL);
    pthread_mutex_init(&ticket.mlock, NULL);

    for (i = 0; i < 100; ++i) {
        if (i % 10 == 0)
            pthread_create(&tid[i], NULL, buy, (void*)names[i]);
        else
            pthread_create(&tid[i], NULL, query, (void*)names[i]);
    }

    for (i = 0; i < 100; ++i) {
        pthread_join(tid[i], NULL);
    }

    pthread_rwlock_destroy(&ticket.rwlock);
    pthread_mutex_destroy(&ticket.mlock);
    printf("remain ticket = %d\n", ticket.remain);

    return 0;
}

编译运行

$ gcc trainticket.c -o trainticket -lpthread
  • 不加锁的运行方式和结果
$ ./trainticket 01

请添加图片描述

这种方式的运行的结果是错误的,最后打印的余票数量是 990 张:

  • 使用读写锁的运行结果*
$ ./trainticket 1

请添加图片描述

  • 使用互斥量的运行结果
$ ./trainticket 2

互斥锁和读写锁的结果都是对的,使用互斥量程序会运行的非常非常慢

4 自己实现互斥锁

void mylock(int *lock) {
	int tmp = 1;
	while (tmp == 1) {
    tmp = *lock; // 如果 *lock 为 1,说明锁被占用,这时候会死循环。
    *lock = 1; // 无论锁有没有被占用,这里都将共享内存的值置 1.
	}
}

如果 lock=0,该进程获得锁,退出循环;如果 lock=1,一直循环。

问题:让tmp = lock; lock = 1没有原子性。

4.1 mylock 实现

void mylock(int* lock) {
    __asm__ __volatile__("1:\n\t"
        "movl $1, %%eax\n\t"
        "lock xchg %%eax, %0\n\t" /* 将 lock 中的值和 eax 进行交换, 相当于 eax = lock, lock = 1 */
        "test %%eax, %%eax\n\t"/* 判断 eax 是否为 1 */
        "jnz 1b" /* 如果为 1 则跳到标记 1 的地方继续执行 */
        ::"m"(*lock)
        : "%eax"
    );
}
  1. asm 表示在 C 语言中内联汇编。
  2. volatile 表示编译时重新从内存取值而非寄存器。
  3. “1:\n\t” 表示位置标记。这是方便指令”jnz 1b”跳转。
  4. $1 :立即数1,“mov $1, %%eax\n\t” 表示置 eax 的内容为1。
  5. %0 表示把后面的 “m”(*lock) 中的 *lock 的值放到这个位置。

4.2 myunlock解锁实现

void myunlock(int* lock) {
    *lock = 0;
}

实验

我们将之前的 luffy 和 allen 抢火车票的例子拿来修改修改,将互斥锁换成我们实现的锁。

程序 myspinlock 通过命令行传参数,传 0 表示不使用锁,传 1 表示使用我们自己写的锁。

// myspinlock.c
#include <unistd.h>
#include <stdio.h>
#include <pthread.h>

int uselock = 0;
int lock;
int tickets = 3;

void myspin_lock(int* lock) {
    __asm__ __volatile__("1:\n\t"
        "movl $1, %%eax\n\t"
        "lock xchg %%eax, %0\n\t" /* 将 lock 中的值和 eax 进行交换, 相当于 eax = lock, lock = 1 */
        "test %%eax, %%eax\n\t"/* 判断 eax 是否为 1 */
        "jnz 1b" /* 如果为 1 则跳到标记 1 的地方继续执行 */
        ::"m"(*lock)
        : "%eax" /* 表示在内联汇编中我用过了 eax 寄存器,通知一下编译器做相关处理以免冲突 */
    );
}

void myspin_unlock(int* lock) {
    *lock = 0;
}

void* allen(void* arg) {
    int flag = 1;
    while (flag) {
        if (uselock)
            myspin_lock(&lock);
        int t = tickets;
        usleep(1000 * 20);// 20ms
        if (t > 0) {
            printf("allen buy a ticket\n");
            --t;
            usleep(1000 * 20);// 20ms
            tickets = t;
        }
        else flag = 0;
        if (uselock)
            myspin_unlock(&lock);
        usleep(1000 * 20);// 20ms
    }
    return NULL;
}

void* luffy(void* arg) {
    int flag = 1;
    while (flag) {
        if (uselock)
            myspin_lock(&lock);
        int t = tickets;
        usleep(1000 * 20);
        if (t > 0) {
            printf("luffy buy a ticket\n");
            --t;
            usleep(1000 * 20);// 20ms
            tickets = t;
        }
        else flag = 0;
        if (uselock)
            myspin_unlock(&lock);
        usleep(1000 * 20);// 20ms
    }
    return NULL;
}
int main(int argc, char* argv[]) {
    if (argc >= 2) uselock = atoi(argv[1]);

    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, allen, NULL);
    pthread_create(&tid2, NULL, luffy, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

编译运行

$ gcc myspinlock.c -o myspinlock -lpthread
$ ./myspinlock 0
allen buy a ticket
luffy buy a ticket
allen buy a ticket
luffy buy a ticket
allen buy a ticket
luffy buy a ticket
$ ./myspinlock 1
luffy buy a ticket
allen buy a ticket
luffy buy a ticket

我们实现的互斥锁与 mutex 互斥量有着本质的不同:

  • 如果一个线程企图获取已加锁状态的互斥量,会立即进入阻塞,即主动让出 cpu
  • 我们自己实现的互斥锁,如果企图获取已加锁状态的 lock 变量,会进入忙等状态而不会让出 cpu

5 自旋锁

自旋锁与上面说的自己实现的互斥锁相似:不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等,不停消耗,执行循环。

自旋锁数据类型:pthread_spinlock_t

初始化和销毁

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);

// pshared = PTHREAD_PROCESS_PRIVATE: 只能被同进程内的线程访问
// pshared = PTHREAD_PROCESS_SHARED: 可以被不同进程内的线程共享

加锁解释函数

int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);

适用场景

  • 临界区的代码短小,没有任何阻塞类的函数。
  • 多核处理器

对于单核或者多核:如果临界区有阻塞,会导致线程被切换到新线程,如果新线程也尝试获取锁,会一直自旋(忙等),直到时间片耗尽,造成不必要的 CPU 浪费。

对于多核没有阻塞的情况:一次只能运行一个线程,其中一个 CPU 上的线程进入临界区,另一个 CPU 上的线程尝试获取锁会自旋,因为它不会阻塞,所以只要稍稍等一下下就能进入临界区,对于多核 CPU 来说,会提高并发率。对于单核 CPU 来说,通常没有什么问题,如果临界区有阻塞或者时间片耗尽产生调度就会浪费 CPU。

6 线程同步

学生线程写作业,老师线程检查作业。要求:只有学生线程写完了,老师线程才能检查。有顺序要求。

  • 轮询:师反复问学生,效率低下
  • 条件变量:学生完成作业后唤醒老师线程

1 条件变量的数据类型和相关函数

数据类型: pthread_cond_t

初始化

  • 通过常量 PTHREAD_COND_INITIALIZER 对其进行静态初始化

  • 通过下面的函数进行初始化和回收:

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);

等待条件发生的函数

// 阻塞版本
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

// 超时版本
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *tsptr);

(1)wait 函数的语义

wait 函数表示将本线程加入等待队列,同时将传入的 mutex 变量解锁,这两步是“原子操作”。所有进入等待队列的线程都在等待条件变量 cond 条件成立。即等待其它线程通过函数 pthread_cond_signal 将等待队列中的某一个线程唤醒,或者使用 pthread_cond_broadcast 函数将所有线程唤醒。一旦线程被唤醒,再次对 mutex 变量加锁

(2) 为什么需要传入互斥量

把 pthread_cond_wait 函数分解成三步:

// 线程 A 中
pthread_mutex_unlock(&lock); // a1
pthread_cond_wait(&cond); // a2
pthread_mutex_lock(&lock); // a3

线程1执行完a1后,假设线程被调度,线程2以为线程1已经加到等待队列中了,其实还没有,导致2唤醒空等待队列,线程1执行a1后,永远等待。

请添加图片描述

未加锁时对条件变量的访问导致线程 A 永远等待下去

(3)为什么在加等待队列前释放互斥锁

必须在加等待队列前释放互斥锁,否则另一线程永远无法进入临界区,死锁。

所以解决此方案的办法就是 a1 和 a2 必须保证是“原子”的,即一次执行完。

实验

学生线程写作业,老师线程检查作业。要求:只有学生线程写完作业了,老师线程才能检查作业。

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

int finished = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* do_homework(void* arg) {
    // doing homework
    sleep(5);
    pthread_mutex_lock(&lock);
    finished = 1;
    pthread_mutex_unlock(&lock);
    // 唤醒队列中的一个线程(如果有线程的话)
    pthread_cond_signal(&cond);
    printf("发送条件信号--------------\n");
}

void* check_homework(void* arg) {
    // 打电话
    sleep(1);
    // 电话接通
    pthread_mutex_lock(&lock);
    // 作业写完了吗?
    printf("老师:作业写完了吗?!\n");
    while (finished == 0) {
        // 没写完呐!
        printf("学生:没写完呐!\n");
        // 好的,你接着写
        printf("老师:好的,你接着写吧!\n");
        printf("-------------------------\n");
        // 因为此时加了锁,所以 finished 变量不可能产生变化。
        // 将线程加入等待队列,线程让出 cpu,同时将 mutex 解锁。
        pthread_cond_wait(&cond, &lock);
        printf("老师:作业写完了吗?!\n");
    }
    printf("学生:写完啦!\n");
    pthread_mutex_unlock(&lock);
    printf("老师开始检查---------------\n");
}


int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, do_homework, NULL);
    pthread_create(&tid2, NULL, check_homework, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

编译运行

$ gcc do_homework.c -o do_homework -lpthread
$ ./do_homework 
老师:作业写完了吗?
学生:没写完呢!
老师:好的,你接着写吧
-------------
发送条件信号------
老师:作业写完了吗?
学生:写完啦
老师开始检查作业--------

2 深入条件变量

如果线程 A 在释放锁后(语句 a1),执行语句 a2 时即将要阻塞的时候,线程 B 此时调用了 pthread_cond_signal 或者 pthread_cond_broadcast,就好像在那个即将要被阻塞的线程 A 已经阻塞过一样。

只有两个线程,线程A对互斥量解锁,请求加入等待队列。同时线程B执行pthread_cond_signal,A不会进入等待队列直接被唤醒了。宏观上看,好像就是从“等待队列”中被唤醒。

虚假唤醒(spurious wakeup)

在多核系统中,要避免 pthread_cond_signal 唤醒超过一个以上的线程,是不可能的。

假设有三个线程,线程A尝试进入等待队列(已经解锁但是尚未阻塞),线程B正在执行 pthread_cond_signal。线程C正处于等待队列中。队列中有线程 C,因此 pthread_cond_signal 会唤醒线程 C。线程A并没有加入等待队列,而是直接唤醒了。

一旦线程C释放锁后,线程A就返回,然而此时,条件可能已经不成立(比如条件被程 C 更改),故出现虚假唤醒的状态。

虚假唤醒解决方案

即使pthread_cond_signal 已经返回,也不意味着条件一定成立,要使用循环 while(finished == 1) 反复测试。代码如下:

pthread_mutex_lock(&lock);
while (finished == 0) {
    pthread_cond_wait(&cond, &lock);
}
pthread_mutex_unlock(&lock);

这里不使用 if,而是while,目的在于防止虚假唤醒。

7 屏障 barrier

请求 barrier 的线程会阻塞,直到所有请求 barrier 的线程达到指定的数量。pthread_join本质上就是一个 barrier,需等待一个线程运行结束才返回。

barrier 的数据类型:pthread_barrier_t

初始化和回收

int pthread_barrier_init(pthread *barrier, const pthread_barrier_t *attr, unsigned int count);
int pthread_barrier_destroy(pthread_barrier_t *barrier);

等待函数

int pthread_barrier_wait(pthread_barrier_t *barrier);

等待函数调用此役,barrier加1,直到等于初始化函数中的count。当barrier 计数值达到count,所有等待的线程被唤醒。只有一个线程让 pthread_barrier_wait 返回 PTHREAD_BARRIER_SERIAL_THREAD,其它线程调用此函数返回 0。

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

pthread_barrier_t b;

void* th_fn(void* arg) {
    int id = (int)arg;
    int res = 0;
    printf("hello, I'm thread [%d]\n", id);
    res = pthread_barrier_wait(&b);
    printf("thread [%d] returning [%d]\n", id, res);
    return NULL;
}

int main() {
    int i, res = 0;
    pthread_t tid[10];
    printf("PTHREAD_BARRIER_SERIAL_THREAD = %d\n", PTHREAD_BARRIER_SERIAL_THREAD);

    pthread_barrier_init(&b, NULL, 11);

    for (i = 0; i < 10; ++i) {
        pthread_create(&tid[i], NULL, th_fn, (void*)i);
    }

    res = pthread_barrier_wait(&b);
    printf("thread [main] returning [%d]\n", res);

    for (i = 0; i < 10; ++i) {
        pthread_join(tid[i], NULL);
    }

    pthread_barrier_destroy(&b);
    return 0;
}

编译运行

$ gcc barrier.c -o barrier -lpthread
$ ./barrier 
PTHREAD_BARRIER_SERIAL_THREAD = -1
hello, I'm thread [0]
hello, I'm thread [1]
hello, I'm thread [2]
hello, I'm thread [3]
hello, I'm thread [4]
hello, I'm thread [5]
hello, I'm thread [6]
hello, I'm thread [7]
hello, I'm thread [8]
hello, I'm thread [9]
thread [main] returning [0]
thread [8] returning [0]
thread [6] returning [0]
thread [5] returning [0]
thread [4] returning [0]
thread [3] returning [0]
thread [9] returning [-1]
thread [1] returning [0]
thread [0] returning [0]
thread [2] returning [0]
thread [7] returning [0]

可以看到,在 barrier 计数还没达到 11 的时候,所有的线程都不会执行 printf(“thread [%d] returning [%d]\n”, id, res);,直到 barrier 计数达到 11 时,才陆续打印 pthread_barrier_wait 的返回值,其中只有一个线程中的返回值为 PTHREAD_BARRIER_SERIAL_THREAD,也就是 -1.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值