文章目录
1 多线程互斥与同步
多线程互斥,同一时间只有一个线程访问数据。
- 互斥锁 mutex
- 读写锁 rwlock
- 自旋锁 spinlock
多线程同步
执行有顺序。只有当我做完了某件事,你才能做另一件事。比如,只有你做完作业了,我才能给你批改。
两种方式实现:
-
你做完作业了,然后你打电话通知我,我再去帮你修改
-
我每隔一段时间打电话问你写完作业没,确认你写完了,我再去帮你修改
条件变量实现功能1。
2 互斥量
2.1 基本概念
为确保同一时间只有一个线程访问数据,在访问共享资源前需要对互斥量上锁。一旦对互斥量上锁后,任何其他试图再次对互斥量上锁的线程都会被阻塞,进入等待队列。
2.2 互斥量数据类型
数据类型:pthread_mutex_t,使用前必须进行初始化。两种初始化方法:
-
静态分配,设置为常量
PTHREAD_MUTEX_INITIALIZER
. -
使用函数
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);
说明:
-
restrict 关键字的含义:访问指针 mutex 指针的内容,唯一方法是使用 mutex 指针。这告诉编译器:除了 mutex 指针指向这个内存,再也没别的指针指向这里。
-
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 读写锁三种状态:
读加锁状态、写加锁状态和不加锁状态。
-
锁处于写加锁状态,其它任何人都只能等待
-
锁处于读加锁状态,这时:
- 在没有人加写锁的情况下,其它线程可以继续加读模式的锁。
- 其它任何加写锁的线程只能等待,同时加写锁后,加读锁只能等待。(防止写线程处于饥饿状态,即长时间不能加写模式锁)
3.2 读写锁的数据类型
数据类型: pthread_rwlock_t。有两种方法初始化:
-
使用宏
PTHREAD_RWLOCK_INITIALIZER
静态初始化读写锁。 -
使用
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"
);
}
- asm 表示在 C 语言中内联汇编。
- volatile 表示编译时重新从内存取值而非寄存器。
- “1:\n\t” 表示位置标记。这是方便指令”jnz 1b”跳转。
- $1 :立即数1,“mov $1, %%eax\n\t” 表示置 eax 的内容为1。
- %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后,永远等待。
(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.