前言:
在上一篇博客中,我们讲解了什么是线程以及如何对线程进行控制,那么了解了这些,我们就可以在程序中创建多线程了,可是多线程往往会带有许多问题,比如竞态条件、死锁、数据竞争、内存泄漏等问题,解决这些问题的关键在于如何实现线程的互斥和同步。
互斥:
- 互斥是指一次只允许一个线程访问共享资源。这意味着当一个线程正在访问共享资源时,其他线程必须等待,直到该线程释放了资源。
- 互斥通常通过互斥锁来实现。当一个线程获得了互斥锁时,其他线程就无法获得该锁,只能等待锁被释放。
同步:
- 同步是指协调多个线程的执行顺序,以确保它们按照预期的顺序执行。
- 同步机制可以确保在多个线程之间正确的共享信息和控制流。常见的同步机制包括信号量、条件变量和屏障等。
- 同步通常用于控制线程之间的竞态条件和避免数据竞争的发生。
今天我们从多线程中的数据竞争问题入手,进一步了解多线程并且利用互斥机制来解决问题。
(一)多线程中的数据竞争
1.相关概念
在分析多线程中的数据竞争问题之前,需要先了解一些相关的概念:
并发访问:
并发指的是在一段时间内,多个任务交替地执行,这些任务可能在同一时间段内启动和执行,但并不一定同时执行。
临界资源&临界区:
- 临界资源是指在多线程环境下需要互斥访问的共享资源,例如共享变量、共享内存区域、文件等。如果多个线程并发地访问和修改临界资源,可能会导致数据竞争和程序错误。
- 临界区是指包含对临界资源访问的代码段或程序区域,这些代码段在任何给定时间点只能被一个线程执行,以确保对临界资源的安全访问。
原子性:
原子性是指在并发编程中操作的不可分割性,即一个操作要么完全执行,要么不执行,不存在中间状态。原子操作在执行过程中不会被中断,也不会被其他线程的操作干扰。
锁:
在并发编程中,锁(Lock)是一种同步机制,用于控制对临界区的访问,确保在任何给定时间点只有一个线程可以进入临界区执行代码。锁主要用于解决多线程环境下的竞态条件和数据竞争问题。
2.多线程抢票场景
在日常生活中,高铁、火车抢票是很平常的一件事。假设票总量为1000,用户进入系统,如果剩余票的数量大于0,那么就代表还有票,用户抢到一张票,剩余票数量减一。这个场景其实就是多线程并发访问的场景,每个用户就代表一个线程,票代表共享资源,下面我们用代码来模拟一下
int ticket = 10000;
void *StartRoutine(void *args)
{const string name = static_cast<char*>(args);while(true){if(ticket > 0){cout<<name<<" get a ticket:"<<ticket<<endl;ticket--;}else{break;}usleep(1000);}return nullptr;
}
int main()
{pthread_t td1,td2,td3,td4;pthread_create(&td1,nullptr,StartRoutine,(void*)"thread-1");pthread_create(&td2,nullptr,StartRoutine,(void*)"thread-2");pthread_create(&td3,nullptr,StartRoutine,(void*)"thread-3");pthread_create(&td4,nullptr,StartRoutine,(void*)"thread-4");pthread_join(td1,nullptr);pthread_join(td2,nullptr);pthread_join(td3,nullptr);pthread_join(td4,nullptr);return 0;
}
可是当我们运行程序时,却发现运行的结果并不完全一致,有时候票的编号甚至会减少到0或-1、-2,这是为什么呢?
显然我们这个程序的多线程并发访问共享数据是有问题的。
3.并发访问问题分析
在这个程序中,对全局变量ticket进行访问的操作有: if(ticket > 0) 和 ticket--;
如果想要对共享数据进行操作,至少要分为三步:
- 将内存中数据的值拷贝到CPU中的寄存器中。
- 在CPU内部通过对寄存器的运算完成操作。
- 将寄存器中的结果拷贝回内存中。
大致图解如下:
如果是在单线程中,上面这三步操作并不会被打断,可是在多线程中,由于上面的操作并不是原子的,而且线程会被调度,所以在中间可能会被打断。
比如线程A对ticket进行--操作,在执行完第二步后,寄存器中的内容已经由100减到99了,然后线程A将要执行第三步时,却发生了线程的调度,例如线程A的时间片到了,然后线程A会保存上下文数据并切走,保存上下文就是将数据单独给自己一份。
这时线程B会开始它对ticket的操作,并且线程B执行的很顺利,在线程A调度完成返回时,线程B已经完成了好几轮操作,内存中的数据被修改只剩1了,这时候线程A回来将继续执行第三步,它会将自己上下文中的数据拷贝回内存,这时候ticket又会编程99,所以多线程中并发访问是不安全的。
(二)互斥锁
通过上面的问题分析,我们要想安全的使用多线程,必须对多个线程都需要访问的共享资源进行保护,也就是将共享资源转变为临界资源。这样就可以实现线程的互斥,通常情况下我们可以使用信号量、条件变量、原子操作、互斥锁、读写锁等,今天我们利用互斥锁来实现线程互斥。
互斥锁(Mutex)是一种常见的同步机制,用于保护临界资源,确保在任何给定时间点只有一个线程能够访问临界资源。其基本原理是在进入临界区之前先锁定互斥锁,然后在退出临界区时释放锁。
1.互斥锁的初始化
我们既可以在程序中定义全局的锁,也可以定义局部的锁,如果使用全局锁,就需要
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
进行初始化
#include <pthread.h>int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
参数:
- pthread_mutex_t *restrict mutex:一个指向锁的指针。
- const pthread_mutexattr_t *restrict attr):是一个互斥锁属性对象的类型,用于指定互斥锁的属性,不需要设置时可以传入nullptr。
返回值:
- 返回值为0:表示函数执行成功,互斥锁初始化成功。
- 返回值为正数(通常是正整数):表示函数执行失败,但没有明确的错误码定义,具体含义可以根据系统的文档或头文件进行查找。
- 返回值为负数:表示函数执行失败,并且会返回一个标准的错误码,可以通过
errno
全局变量获取,具体的错误码可以通过查看系统头文件<errno.h>
来获得。
2.互斥锁的释放
释放一个锁可以通过pthread_mutex_destroy() 函数来实现
#include <pthread.h>int pthread_mutex_destroy(pthread_mutex_t *mutex);
返回值:
- 返回值为0:表示函数执行成功,互斥锁销毁成功。
- 返回值为正数(通常是正整数):表示函数执行失败,但没有明确的错误码定义,具体含义可以根据系统的文档或头文件进行查找。
- 返回值为负数:表示函数执行失败,并且会返回一个标准的错误码,可以通过
errno
全局变量获取,具体的错误码可以通过查看系统头文件<errno.h>
来获得。
3.互斥锁加锁
对临界资源加锁,需要使用pthread_mutex_lock()函数,它用于获取(加锁)互斥锁的函数。它的作用是在进入临界区之前,尝试获取互斥锁,如果互斥锁已经被其他线程持有,则当前线程会被阻塞,直到获取到互斥锁为止。
#include <pthread.h>int pthread_mutex_lock(pthread_mutex_t *mutex);
返回值:
- 返回值为0表示函数执行成功,当前线程成功获取了互斥锁。
- 返回值为正数表示函数执行失败,但没有明确的错误码定义,具体含义可以根据系统的文档或头文件进行查找。
- 返回值为负数表示函数执行失败,并且会返回一个标准的错误码,可以通过
errno
全局变量获取,具体的错误码可以通过查看系统头文件<errno.h>
来获得。
4.互斥锁解锁
对临界资源解锁,需要使用pthread_mutex_unlock()函数,它是用于释放(解锁)互斥锁的函数。它的作用是在临界区代码执行完毕后,释放互斥锁,以便其他线程可以获取到互斥锁进入临界区执行代码。
#include <pthread.h>int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:
- 返回值为0表示函数执行成功,互斥锁成功释放。
- 返回值为正数表示函数执行失败,但没有明确的错误码定义,具体含义可以根据系统的文档或头文件进行查找。
- 返回值为负数表示函数执行失败,并且会返回一个标准的错误码,可以通过
errno
全局变量获取,具体的错误码可以通过查看系统头文件<errno.h>
来获得。
5.代码示例
pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;int ticket = 1000;void *StartRoutine(void *args)
{const string name = static_cast<char *>(args);while (true){pthread_mutex_lock(&_mutex);if (ticket > 0){cout << name << " get a ticket:" << ticket << endl;ticket--;sum++;pthread_mutex_unlock(&_mutex);}else{pthread_mutex_unlock(&_mutex);break;}usleep(1000);}return nullptr;
}
int main()
{pthread_mutex_init(&_mutex, nullptr);pthread_t td1, td2, td3, td4;pthread_create(&td1, nullptr, StartRoutine, (void *)"thread-1");pthread_create(&td2, nullptr, StartRoutine, (void *)"thread-2");pthread_join(td1, nullptr);pthread_join(td2, nullptr);pthread_mutex_destroy(&_mutex);return 0;
}
上面的代码利用互斥锁实现了线程的互斥,使得在多个线程抢票的时候不会出现数据竞争的问题,也就不会让票的数量出现异常。
(三)锁的本质
大多数体系结构都提供了exchange或swap命令,该指令的作用是将寄存器和内存单元的数据进行交换,这个交换过程是原子的。
将pthread_mutex_lock()函数的汇编代码抽象出来
lock:movb $0, %alxchgb %al, mutexif(al寄存器里的内容 > 0){return 0;} else挂起等待;goto lock;
xchgb作用:将一个共享的mutex资源,交换到自己的上下文中,属于线程自己 。
这段伪代码的过程可以概括为:
- movb $0, %al:将0赋值给al寄存器中。
- xchgb %al, mutex :将mutex的值赋值给al寄存器,我们默认mutex是1(大于0的值)。
- 判断al寄存器中的数据是否大于0,如果大于0返回0,代表获取锁成功;如果小于0,就挂起等待,代表锁已经被别人获取了。
再看pthread_mutex_unlock()函数
unlcok:movb $l,mutex 唤醒等待Mutex的线程;return 0;
- 将线程上下文中mutex资源跟内存中的mutex交换。
我对加锁解锁的理解就是:将锁看作一把钥匙。临界区看作一间房子,钥匙原本挂在房子里,第一个进入的线程会把钥匙放到自己口袋(上下文)里,如果线程在临界区被调度走,它会把钥匙也带走并关上房门,这样别的线程想要进来但是没有钥匙,而有钥匙的线程回来时还能够进入房子里,当线程解锁,就将钥匙放回到房子里,并打开房门。
(四)死锁
1.概念
死锁是在并发系统中的一种常见问题,它指的是两个或多个进程或线程因相互持有对方所需的资源而无法继续执行的状态。在死锁状态下,各进程或线程都在等待其他进程或线程释放资源,而导致它们都无法继续执行,从而形成了一种僵局。
2.必要条件
-
互斥条件:一个资源只能每次只能被一个执行流使用。
-
请求与保持条件:一个执行流因为请求资源而阻塞时,对已获得的资源保持不放。
-
不剥夺条件:一个执行流已获得的资源,在未使用之前,不能强行剥夺。
-
循环等待条件:若干执行流之间形成一种头尾相连的循环等待资源的关系。
3.解决方法
- 资源分配策略:设计合理的资源分配策略,避免同时持有多个资源,从而减少死锁的发生可能性。
- 加锁顺序:确保所有进程或线程都以相同的顺序请求资源,从而避免形成循环等待。
- 超时机制:对于资源请求,设置超时机制,如果超过一定时间仍未能获取资源,则放弃当前请求,避免长时间等待而导致死锁。
- 死锁检测和解除:定期检测系统中是否存在死锁,并采取相应的措施来解除死锁,例如终止部分进程或线程,释放资源等。
(五)线程同步
1.同步概念
在上面的互斥示例程序中,我们用抢票的例子来实现线程互斥,可是在打印时却有一个现象:总是有一个线程会抢占大多数的票,导致了其他线程一直在等待,抢不上票。这种现象叫做线程饥饿问题,指的是一个或多个线程无法获得所需的资源或者无法被调度执行而长时间等待。
那么如何解决饥饿问题呢?这就需要同步了。接下来我用一个比喻来理解线程同步。
假设有一个VIP自习室,每次只能让一个同学进入学习,只要自习室内有人,别的同学就无法进入
张三在一天的早上6点第一个到达自习室,并且一直在自习室里学习,这就表示张三一直在使用里面的资源。到了中午12点,张三想去吃饭,但是吃饭出去的话就必须将钥匙归还,然后吃完饭回来就得等待其他同学出来,可张三并不想等,所以张三又饿又不想出去,他将钥匙放回去又拿回来,在自习室里反复横跳。最后外面的同学一直没等到钥匙,没吃上饭,而张三一直持有钥匙,也没吃上饭,这就导致了其他同学的饥饿问题。
管理人员知道了这件事,定了下面两条规矩:
- 刚把钥匙归还的同学不能再次立即申请钥匙。
- 在外面等待钥匙的同学必须排队。
定了这两条规矩,张三再也不会反复横跳了,也就不会导致饥饿问题了。这就是利用线程同步。
线程同步是指在多线程环境中,对共享资源的访问进行协调和管理,以确保线程之间的正确交互和数据一致性。在并发编程中,线程同步是至关重要的,因为多个线程同时访问共享资源可能导致数据竞争和不确定性的结果。
2.条件变量
条件变量是在多线程编程环境中用来线程通信的机制,它通常和互斥锁使用来实现线程同步的效果。
条件变量实现了线程的等待和通知机制:
-
等待(Wait):线程在等待条件变量时会释放它所持有的互斥锁,并进入阻塞状态,直到其他线程通知条件变量满足了某个条件。
-
通知(Notify):线程在某个条件发生变化时可以通过条件变量通知等待条件变量的一个或多个线程,以唤醒它们继续执行。
2.1创建和销毁
#include <pthread.h>int pthread_cond_destroy(pthread_cond_t *cond);int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
2.2等待
#include <pthread.h>int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
pthread_cond_wait
函数会使当前线程等待在指定的条件变量 cond
上,同时会释放传入的互斥锁 mutex
,并将当前线程置于等待状态,直到有其他线程调用 pthread_cond_signal
或 pthread_cond_broadcast
来唤醒它,或者出现了异常情况(如信号中断)。
2.3唤醒
#include <pthread.h>int pthread_cond_signal(pthread_cond_t *cond);
#include <pthread.h>int pthread_cond_broadcast(pthread_cond_t *cond);
-
pthread_cond_signal:
pthread_cond_signal
函数用于唤醒等待在条件变量上的一个线程。- 如果有多个线程等待在条件变量上,调用
pthread_cond_signal
只会唤醒其中一个线程,具体唤醒哪个线程由系统决定(通常是按照先等待先唤醒的顺序)。 - 如果没有线程等待在条件变量上,调用
pthread_cond_signal
也不会产生任何效果。
-
pthread_cond_broadcast:
pthread_cond_broadcast
函数用于唤醒等待在条件变量上的所有线程。- 调用
pthread_cond_broadcast
会唤醒所有等待在条件变量上的线程,使它们都可以继续执行。 - 如果没有线程等待在条件变量上,调用
pthread_cond_broadcast
也不会产生任何效果。