线程给我们带来的优势
线程大部分资源都是共享的,给线程的最大好处就是可以通信方便,缺点是缺乏访问控制。因为一个线程的操作问题,给其他线程造成了不可控,或者引起崩溃的,异常,逻辑不正确等等现象,我们可以称为线程安全。基于这样的原因我们后续的访问控制需要引入互斥,同步。
我们用例子来演示:
例如,下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。
#include
#include
#include
int tickets = 1000;
void* TicketGrabbing(void* arg)
{
const char* name = (char*)arg;
while (1){
if (tickets > 0)
{
usleep(10000);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
}
else{
break;
}
}
printf("%s quit!\n", name);
pthread_exit((void*)0);
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
运行结果显然不符合我们的预期,因为其中出现了剩余票数为负数的情况。
出现负数的情况就是因为没有对临界区进行互斥操作。如下图:
我们模拟一下为什么会出现负数情况,我们有线程A,线程B,票数为1000;线程A第一次抢到1张票时,还剩999张票,当线程A再次抢票操作时,完成了第一步操作后,因为时间片的原因被OS切换,这时候线程A把票数为999的数据放到自己的上下文数据里保存,轮到线程B去抢票,线程B抢完了,还剩下1张票,这时候切换回线程A,线程A恢复上下文数据,这时候CPU看到的票数为999,最后--
运算后,把998写入到内存变量tickets里,不难发现这时候就造成了,数据错误的问题,由于抢票的操作--tickets
非原子操作,中途的修改可能印象其他线程,或者其他线程影响到自己线程。所以后续我们要学习互斥锁,让线程A先完成临界区的操作后,其他线程才能操作临界区,其他线程也一样。
usleep
这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段--ticket
操作本身就不是一个原子操作要解决抢票问题,需要做到三点:
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
简单理解互斥锁
类似锁和钥匙,钥匙和锁各有其一,进入临界区时必须使用钥匙打开锁,并且进入临界区后也要带着钥匙,出临界区后需要把钥匙放到公共区里方便给其他线程竞争去使用。即没钥匙就进不去,只有一把钥匙,每次就只能进去一个线程。这样就达到互斥和原子了。
初始化互斥量
初始化互斥量的函数叫做pthread_mutex_init,该函数的函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
返回值说明:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量
销毁互斥量的函数叫做pthread_mutex_destroy,该函数的函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
返回值说明:
销毁互斥量需要注意:
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要销毁。互斥量加锁
互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
返回值说明:
调用pthread_mutex_lock
时,可能会遇到以下情况:
互斥量解锁
互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
返回值说明:
修改正确抢票代码
我们只需要对tickets变量的临界区添加互斥锁进行保护即可。
如下:
#include
#include
#include
int tickets = 1000;
pthread_mutex_t mutex;
void* TicketGrabbing(void* arg)
{
const char* name = (char*)arg;
while (1){
pthread_mutex_lock(&mutex);
if (tickets > 0){
usleep(100);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
pthread_mutex_unlock(&mutex);
}
else{
pthread_mutex_unlock(&mutex);
break;
}
}
printf("%s quit!\n", name);
pthread_exit((void*)0);
}
int main()
{
pthread_mutex_init(&mutex, NULL);
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
注意:
互斥锁的本质
其实就是一个整数变量。
自习室场景:自习室有一个门,有一把钥匙,每次只能通过钥匙进入自习室,出来时也需要把钥匙放到公有处。mutex变量就是放置着钥匙的公有处。我们使用1数字表示钥匙,当进入自习室时,mutex的钥匙被拿走,执行mutex--
此时mutex=0表示钥匙被拿走了,其他人进入必须要有钥匙。从自习室出来时,执行mutex++
语句。这时候我们可以再次竞争钥匙了。
互斥量实现原理探究
经过上面的例子,大家已经意识到单纯的 --
或者 ++
都不是原子的,有可能会有数据一致性问题为了实现互斥锁操作,互斥锁本身也是临界资源所以自身必须是安全的,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,我们把一条汇编语句的操作看作是原子性的,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下,
如下图:
movb $0, $al
:把0赋值给寄存器al里。
xchgb $al ,mutex
:al 与 mutex交换,使用一条汇编,原子性的完成共享的内存数据mutex,交换到线程A的上下文中,从而实现私有的过程。
if
语句判断是否申请锁成功了,申请成功的线程会把mutex的值带走,如果申请失败挂起等待等待锁的资源,当有锁资源时(拿着锁的线程返回锁以后),被唤醒并且回到lock
语句,然后与其他线程再次竞争锁。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。
结论:可重入函数大于线程安全。
造成死锁的例子
#include
#include
#include
#include
#include
#include
pthread_mutex_t mutex;
void insert()
{
pthread_mutex_lock(&mutex);
//1
//2
//3 收到信号
//......
pthread_mutex_unlock(&mutex);
}
// 二号信号捕捉后的处理函数。
void handler(int signal)
{
insert();
}
int main(int argc, char const *argv[])
{
signal(2,handler);// 捕捉二号信号
insert();
return 0;
}
在单执行流下也会造成死锁
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
#include
#include
pthread_mutex_t mutex;
void* Routine(void* arg)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
pthread_exit((void*)0);
}
int main()
{
pthread_t tid;
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid, NULL, Routine, NULL);
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
代码运行后,用ps
命令查看该进程时可以看到,该进程当前的状态是Sl+,其中的l实际上就是lock的意思,表示该进程当前处于一种死锁的状态。
简单理解同步
饭堂的例子:饭堂大妈负责打饭,学生负责等饭,大妈在给一个学生打饭时是互斥的,在这期间不能给其他同学打饭,大妈只负责打饭并且一次只能给一个学生,在下次打饭时,学生的竞争性最强的优先可以打饭。如果该学生一直是最优先的,该学生再次打饭时,那么其他学生打不着饭造成“饥饿问题”。因此要引入同步,我们可以规定打完饭的同学如果还要继续打饭那么只能排到队伍后面,这就具有合理性了。
虽然互斥能解决只允许一个执行流进入临界区,但是每次进入临界区都是不知道临界资源的具体状态,例如一个线程访问队列时,队列为空,这时候它应该挂起等待直到其它线程将一个节点添加到队列中,队列不为空时,但是它没有等待而是往后执行了,这就出现问题了。这种情况就需要用到条件变量。
条件变量的结构体
条件变量的结构为 pthread_cond_t
初始化
原型:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
函数说明:
pthread_cond_t
的指针,cond_attr
是一个指向结构pthread_condattr_t
的指针。结构 pthread_condattr_t
是条件变量的属性结构,和互斥锁一样我们可以用它来设置条件变量是进程内可用还是进程间可用,默认值是 PTHREAD_ PROCESS_PRIVATE
,即此条件变量被同一进程内的各个线程使用。注意初始化条件变量只有未被使用时才能重新初始化或被释放。参数说明:
返回值说明:
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
返回值说明:
销毁条件变量需要注意:
PTHREAD_COND_INITIALIZER
初始化的条件变量不需要销毁。等待条件变量满足
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
函数说明:
pthread_cond_signal()
和函数 pthread_cond_broadcast()
唤醒,但是要注意的是,条件变量只是起阻塞和唤醒线程的作用,具体的判断条件还需用户给出,例如一个变量是否为0才能执行等待或者唤醒其他线程等等,这一点我们从后面的例子中可以看到。线程被唤醒后,它将重新检查判断条件是否满足,如果还不满足,一般说来线程应该仍阻塞在这里,被等待被下一次唤醒。这个过程一般用 while语句 实现,后面有例子说明此情况,这种情况出现在伪唤醒。参数说明:
返回值说明:
唤醒等待
唤醒等待的函数有以下两个:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
函数说明:
区别:
pthread_cond_signal
函数用于唤醒等待队列中首个线程。 pthread_cond_broadcast
函数用于唤醒等待队列中的全部线程。参数说明:
返回值说明:
例子:一个控制线程控制其他线程
#include
#include
#include
#include
pthread_mutex_t mtx;
pthread_cond_t cond;
//ctrl thread 控制work线程,让他定期运行
void *ctrl(void *args)
{
std::string name = (char*)args;
while(true){
//pthread_cond_signal: 唤醒在条件变量下等待的一个线程,哪一个??
//在cond 等待队列里等待的第一个线程
std::cout << "master say : begin work" << std::endl;
// 一次只唤醒一个线程
// pthread_cond_signal(&cond);
//唤醒所有线程
pthread_cond_broadcast(&cond);
sleep(5);
}
}
void *work(void *args)
{
int number = *(int*)args;
delete (int*)args;
while(true){
//此处我们的mutex不用,暂时这样样,后面解释
pthread_cond_wait(&cond, &mtx);
std::cout << "worker: " << number << " is working ..." << std::endl;
}
}
int main()
{
#define NUM 3
// 初始化锁资源和条件变量
pthread_mutex_init(&mtx, nullptr);
pthread_cond_init(&cond, nullptr);
// 创建控制线程和工作线程
pthread_t master;
pthread_t worker[NUM];
pthread_create(&master, nullptr, ctrl, (void*)"boss");
for(int i = 0; i < NUM; i++){
int *number = new int(i);
pthread_create(worker+i, nullptr, work, (void*)number);
}
for(int i = 0; i < NUM; i++){
pthread_join(worker[i], nullptr);
}
pthread_join(master, nullptr);
// 销毁锁资源和条件变量
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
pthread_cond_t
的大概结构
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
错误的设计
如下代码:
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
pthread_cond_wait
之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait
将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait
。所以解锁和等待必须是一个原子操作。理解错误设计
如下图:
两份伪代码,线程A第一步先释放锁资源,条件变量cond为临界资源,第二步线程B满足条件后给条件变量cond发送信号,第三步线程A等待条件变量cond,这时候线程A错过了线程B的条件变量信号。
正确的设计
int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex);
进入该函数后,会去看条件量等于0不?如果等于0,说明此时需要将对应的互斥锁解锁,直到cond_ wait
返回,把条件量改成1,把互斥量恢复成原样。如下代码:
等待条件代码:
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号代码:
pthread_mutex_lock(&mutex);
if(条件为真)
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);