共计 7194 个字符,预计需要花费 18 分钟才能阅读完成。
-
线程间同步形式
- 引言
-
互斥锁
-
探索底层,实现一个锁
- 测试并加锁(TAS)
- 比拟并替换(CAS)
- 另一个问题,过多的自旋?
- 回到互斥锁
-
-
信号量
- 有名信号量
- 无名信号量
- 总结
-
条件变量
- 什么是条件变量?
-
相干函数
- 1. 初始化
- 2. 期待条件
- 3. 告诉条件
- 用法与思考
- 实际——读写者锁
文章已收录至我的仓库:Java 学习笔记与收费书籍分享
线程间同步形式
引言
不同线程间对临界区资源的拜访可能会引起常见的并发问题,咱们心愿线程原子式的执行一系列指令,但因为单处理器上的中断,咱们必须想一些其余方法以同步各线程,本文就来介绍一些线程间的同步形式。
互斥锁
互斥锁(又名互斥量),强调的是资源的拜访互斥:互斥锁是用在多线程多任务互斥的,当一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程 unlock,其余的线程才开始能够利用这个资源。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
留神了解 trylock 函数,这与一般的 lock 不一样,一般的 lock 函数在资源被锁住时会被梗塞,直到锁被开释。
trylock 函数是非阻塞调用模式,也就是说如果互斥量没被锁住,trylock 函数将把互斥量加锁,并取得对共享资源的拜访权限; 如果互斥量被锁住了,trylock 函数将不会阻塞期待而间接返回 EBUSY,示意共享资源处于忙状态,这样就能够防止死锁或饿死等一些极其状况产生。
探索底层,实现一个锁
实现一个锁必须须要硬件的反对,因为咱们必须要保障锁也是并发平安的,这就须要硬件反对以保障锁外部是原子实现的。
很容易想到保护一个全局变量 flag,当该变量为 0 时,容许线程加锁,并设置 flag 为 1;否则,线程必须挂起期待,直到 flag 为 0.
typedef struct lock_t {int flag;}lock_t;
void init(lock_t &mutex) {mutex->flag = 0;}
void lock(lock_t &mutex) {while (mutex->flag == 1) {;} // 自旋期待变量为 0 才可进入
mutex->flag = 1;
}
void unlock(lock_t &mutex) {mutex->flag = 0;}
这是基于软件的初步实现,初始化变量为 0,线程自旋期待变量为 0 才可进入,这看上去仿佛并没有什么故障,然而认真思考,这是有问题的:
当线程恰好通过 while 断定时陷入中断,此时并未设置 flag 为 1,另一个线程闯入,此时 flag 依然为 0,通过 while 断定进入临界区,此时中断,回到原线程,原线程继续执行,也进入临界区,这就造成了同步问题。
在 while 循环中,仅仅设置 mutex->flag == 1 是不够的,只管他是一个原语,咱们必须有更多的代码,同时,当咱们引入更多代码时,咱们必须保障这些代码也是原子的,这就意味着咱们须要硬件的反对。
咱们思考下面代码为什么会失败?起因是当退出 while 循环时,在这一时刻 flag 依然为 0,这就给了其余线程抢入临界区的机会。
解决办法也很直观 —— 在退出 while 时,借助硬件反对保障 flag 被设置为 1。
测试并加锁(TAS)
咱们编写如下函数:
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr;
*old_ptr = new;
return old;
}
同时从新设置 while 循环:
void lock(lock_t &mutex) {while (TestAndSet(mutex->flag,1) == 1) {;} // 自旋期待变量为 0 才可进入
mutex->flag = 1;
}
这里,咱们借助硬件,保障 TestAndSet 函数是原子执行的,当初锁能够正确的应用了。当 flag 为 0 时,咱们通过 while 测试时曾经将 flag 设置为 1 了,其余线程曾经无奈进入临界区。
比拟并替换(CAS)
咱们编写如下函数:
int CompareAndSwap(int *ptr, int expected, int new) {
int actual = *ptr;
if (actual == expected) {*ptr = new;}
return actual;
}
同样的,硬件也应该反对 CAS 原语以保障 CAS 外部也是平安的,当初从新设置 while:
void lock(lock_t &mutex) {while (CompareAndSwap(mutex->flag,0,1) == 1) {;} // 自旋期待变量为 0 才可进入
mutex->flag = 1;
}
当初锁能够正确的应用了,当 flag 为 0 时,咱们通过 while 测试时曾经将 flag 设置为 1 了,其余线程曾经无奈进入临界区。
此外你可能发现 CAS 所须要更多的寄存器,在未来钻研 synchronozation
时,你会发现它的妙处。
另一个问题,过多的自旋?
你可能发现了,只管一个线程未能取得锁,其依然在一直 while 循环以占用 CPU 资源,一个方法就是当线程未能取得锁,进入休眠以开释 CPU 资源(条件变量),当一个线程开释锁时,唤醒一个正在休眠的线程。不过这样也有毛病,进入休眠与唤醒一个锁也是须要工夫的,当一个线程很快就能开释锁时,多等等是比陷入休眠更好的抉择。
Linux 下采纳两阶段锁,第一阶段线程自旋肯定工夫或次数期待锁的开释,当达到肯定工夫或肯定次数时,进入第二阶段,此时线程进入休眠。
回到互斥锁
互斥锁提供了并发平安的根本保障,互斥锁用于保障对临界区资源的平安拜访,但何时须要拜访资源并不是互斥锁应该思考的事件,这可能是条件变量该思考的事件。
如果线程频繁的加锁和解锁,效率是十分低效的,这也是咱们必须要思考到的一个点。
信号量
信号量并不用来传送资源,而是用来爱护共享资源,了解这一点是很重要的,信号量 s 的示意的含意为 同时容许拜访资源的最大线程数量,它是一个全局变量。
在过程中也能够应用信号量,对于信号量的了解过程中与线程中并无太大差别,都是用来爱护资源,对于更多信号量的了解参见这篇文章: JavaLearningNotes/ 过程间通信形式。
来思考一个下面简略的例子:两个线程同时批改而造成谬误,咱们不思考读者而仅仅思考写者过程,在这个例子中共享资源最多容许一个线程批改资源,因而咱们初始化 s 为 1。
开始时,A 率先写入资源,此时 A 调用 P(s),将 s 减一,此时 s = 0,A 进入共享区工作。
此时,线程 B 也想进入共享区批改资源,它调用 P(s)发现此时 s 为 0,于是挂起线程,退出期待队列。
A 工作结束,调用 V(s),它发现 s 为 0 并检测到期待队列不为空,于是它随机唤醒一个期待线程,并将 s 加 1,这里唤醒了 B。
B 被唤醒,继续执行 P 操作,此时 s 不为 0,B 胜利执行将 s 置为 0 并进入工作区。
此时 C 想要进入工作区 ……
能够发现,在无论何时只有一个线程可能访问共享资源,这就是信号量做的事件,他管制进入共享区的最大过程数量,这取决于初始化 s 的值。尔后,在进入共享区之前调用 P 操作,出共享区后调用 V 操作,这就是信号量的思维。
有名信号量
有名信号量以文件的模式存在,即时是不同过程间的线程也能够拜访该信号量,因而能够用于不同过程间的多线程间的互斥与同步。
创立关上有名信号量
sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
// 胜利返回信号量指针;失败返回 SEM_FAILED,设置 errno
name 是文件路径名,value 设置为信号量的初始值。
敞开信号量,过程终止时,会调用它
int sem_close(sem_t *sem); // 胜利返回 0;失败返回 -1,设置 errno
删除信号量,立刻删除信号量名字,当其余过程都敞开它时,销毁它
int sem_unlink(const char *name);
期待信号量,测试信号量的值,如果其值小于或等于 0,那么就期待(阻塞);一旦其值变为大于 0 就将它减 1,并返回
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
// 胜利返回 0;失败返回 -1,设置 errno
当信号量的值为 0 时,sem_trywait 立刻返回,设置 errno 为 EAGAIN。如果被某个信号中断,sem_wait 会过早地返回,设置 errno 为 EINTR
发出信号量,给它的值加 1,而后唤醒正在期待该信号量的过程或线程
int sem_post(sem_t *sem);
胜利返回 0;失败返回 -1,不会扭转它的值,设置 errno,该函数是异步信号平安的,能够在信号处理程序里调用它
无名信号量
无名信号量存在于过程内的虚拟空间中,对于其余过程是不可见的,因而无名信号量用于一个过程体内各线程间的互斥和同步, 应用如下 API:
(1)sem_init 性能:用于创立一个信号量,并初始化信号量的值。函数原型:
int sem_init (sem_t* sem, int pshared, unsigned int value);
函数传入值:sem: 信号量。pshared: 决定信号量是否在几个过程间共享。因为目前 LINUX 还没有实现过程间共享信息量,所以这个值只能取 0。
(2)其余函数
int sem_wait (sem_t* sem);
int sem_trywait (sem_t* sem);
int sem_post (sem_t* sem);
int sem_getvalue (sem_t* sem);
int sem_destroy (sem_t* sem);
性能:
sem_wait 和 sem_trywait 相当于 P 操作,它们都能将信号量的值减一,两者的区别在于若信号量的值小于零时,sem_wait 将会阻塞过程,而 sem_trywait 则会立刻返回。
sem_post 相当于 V 操作,它将信号量的值加一,同时收回唤醒的信号给期待的线程。
sem_getvalue 失去信号量的值。
sem_destroy 捣毁信号量。
如果某个基于内存的信号量是在不同过程间同步的,该信号灯必须寄存在共享内存区中,这要只有该共享内存区存在,该信号灯就存在。
总结
无名信号量存在于内存中,有名信号量是存在于磁盘上的,因而无名信号量的速度更快,但只实用于一个独立过程内的各线程;有名信号量能够速度欠缺,但能够使不同过程间的线程同步,这是通过共享内存实现的,共享内存是过程间的一种通信形式。
你可能发现了,当信号量的值 s 为 1 时,信号量的作用于互斥锁的作用是一样的,互斥锁只能容许一个线程进入临界区,而信号量容许更多的线程进入临界区,这取决于信号量的值为多少。
条件变量
什么是条件变量?
在互斥锁中,线程期待 flag 为 0 能力进入临界区;信号量中 P 操作也要期待 s 不为 0 …… 在多线程中,一个线程期待某个条件是很常见的,互斥锁实现一节中,咱们采纳自旋是否有一个更专门、更高效的形式实现条件的期待?
它就是条件变量!条件变量 (condition variable) 是利用线程间共享的全局变量进行同步的一种机制,次要包含两个动作:一个线程期待某个条件为真,而将本人挂起;另一个线程设置条件为真,并告诉期待的线程持续。
因为某个条件是全局变量,因而 条件变量常应用互斥锁以爱护(这是必须的,是被强制要求的)。
条件变量与互斥量一起应用时,容许线程以无竞争的形式期待特定的条件产生。
线程能够应用条件变量来期待某个条件为真,留神了解并不是期待条件变量为真,条件变量 (cond) 是在多线程程序中用来实现 ” 期待 –> 唤醒 ” 逻辑罕用的办法,用于保护一个条件(与是条件变量不同的概念),并不是说期待条件变量为真或为假。条件变量是一个显式的队列,当条件不满足时,线程将本人退出期待队列,同时开释持有的互斥锁;当一个线程扭转条件时,能够唤醒一个或多个期待线程(留神此时条件不肯定为真)。
在条件变量上有两种基本操作:
- 期待(wait):一个线程处于期待队列中休眠,此时线程不会占用互斥量,当线程被唤醒后,从新取得互斥锁(可能是多个线程竞争),并从新取得互斥量。
- 告诉(signal/notify):当条件更改时,另一个线程发送告诉以唤醒期待队列中的线程。
相干函数
1. 初始化
条件变量采纳的数据类型是 pthread_cond_t,,在应用之前必须要进行初始化,,这包含两种形式:
动态: 间接设置条件变量 cond 为常量 PTHREAD_COND_INITIALIZER。
动静: pthread_cond_init 函数, 是开释动静条件变量的内存空间之前, 要用 pthread_cond_destroy 对其进行清理。
int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
// 胜利则返回 0, 出错则返回谬误编号.
留神:条件变量占用的空间并未被开释。
cond:要初始化的条件变量;attr:个别为 NULL。
2. 期待条件
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restric mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict timeout);
// 胜利则返回 0, 出错则返回谬误编号.
这两个函数别离是阻塞期待和超时期待,梗塞等到进入期待队列休眠直到条件批改而被唤醒;超时期待在休眠肯定工夫后主动醒来。
进入期待时线程开释互斥锁,而在被唤醒时线程从新取得锁。
3. 告诉条件
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
// 胜利则返回 0, 出错则返回谬误编号.
这两个函数用于告诉线程条件已被批改,调用这两个函数向线程或条件发送信号。
用法与思考
条件变量用法模板:
pthread_cond_t cond; // 条件变量
mutex_t mutex; // 互斥锁
int flag; // 条件
// A 线程
void threadA() {Pthread_mutex_lock(&mutex); // 爱护临界资源,因为线程会批改全局条件 flag
while (flag == 1) // 期待某条件成立
Pthread_cond_wait(&cond, &mutex); // 不成立则退出队列休眠,并开释锁
....dosomthing
....change flag // 条件被批改
Pthread_cond_signal(&cond); // 发送信号告诉条件被批改
Pthread_mutex_unlock(&mutex); // 放松信号后尽量疾速开释锁,因为被唤醒的线程会尝试取得锁
}
// B 线程
void threadB() {Pthread_mutex_lock(&mutex); // 爱护临界资源
while (flag == 0) // 期待某条件成立
Pthread_cond_wait(&cond, &mutex); // 不成立则退出队列休眠,并开释锁
....dosomthing
....change flag // 条件被批改
Pthread_cond_signal(&cond); // 放松信号后尽量疾速开释锁,因为被唤醒的线程会尝试取得锁
Pthread_mutex_unlock(&mutex);
}
通过下面的一个例子,应该很好了解条件变量与条件的区别,条件变量是一个逻辑,它并不是 while 循环里的 bool 语句,我置信很多初学者都有这么一个误区,即条件变量就是线程须要期待的条件。条件是条件,线程期待条件而不是期待条件变量,条件变量使得线程更高效的期待条件成立,是一组期待 — 唤醒 的逻辑。
留神这里依然要应用 while 循环期待条件,你可能会认为明明曾经上锁了别的线程无奈强入。事实上当线程 A 陷入休眠时会开释锁,而当其被唤醒时,会尝试取得锁,而正在其尝试取得锁时,另一个线程 B 当初尝试取得锁,并且抢到锁进入临界区,而后批改条件,使得线程 A 的条件不再成立,线程 B 返回,此时线程 A 终于取得锁了,并进入临界区,但此时线程 A 的条件基本曾经不成立,他不该进入临界区!
此外,被唤醒也不代表条件成立了,例如上述代码线程 B 批改 flag = 3,并且唤醒线程 A,这里线程 A 的条件基本不合乎,所以必须反复断定条件。互斥锁和条件变量的例子通知咱们:在期待条件时,总是应用 while 而不是 if!
陷入休眠的线程必须开释锁也是有意义的,如果不开释锁,其余线程根本无法批改条件,休眠的线程永远都不会醒过来!
实际——读写者锁
读取锁——共享;写入锁——独占。即:读线程能够加多个,而写线程只能有一个,并且读者和写者不能同时工作。
这种状况下因为容许多个读者共享临界区效率会高效,咱们来思考实现的问题:只容许一个写者工作,那么肯定须要一个互斥量或二值信号量来保护,咱们称为写者锁;因为读者和写者不能同时工作,第一个读者必须尝试获取写者锁,而一旦读者数量大于 1,则后续读者毋庸尝试获取写者锁而可间接进入,留神到这里存在全局读者数量变量,因而读者也须要一个锁以保护全局读者数量,最初一个退出的读者必须负责开释读者锁。
通晓原理,快去本人入手实现一个读写者锁把!
Linux 下通过 pthread_rwlock
函数族实现。