并发是指同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上有多个进程被同时执行的效果–宏观上并行,针对单核处理器。
并行就是同一时刻,有多条指令在多个处理器上同时执行,针对多核处理器
并发关系有两种,分别是同步和互斥:
临界资源:是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。
临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
线程的优势:
线程的缺陷:
对于进程来说,相同的地址(同一个虚拟地址)在不同的进程中,反复使用而不冲突。原因是他们虽虚拟址一样,但,页目录、页表、物理页面(页表的三级映射)各不相同。相同的虚拟址,映射到不同的物理页面内存单元,最终访问不同的物理页面。But!,两个线程具有各自独立的PCB,但共享同一个页目录,也就共享同一个页表和物理页面。所以两个PCB共享一个地址空间。
线程间的共享资源:
线程间的非共享资源:
对于Linux而言,没有真的线程,只是一种轻量级的进程。所以Linux的线程库并不是操作系统提供的,而是一个第三方库。
代码如下:
//功能:创建一个新的线程
//原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
//参数
//thread:返回线程ID
//attr:设置线程的属性,attr为NULL表示使用默认属性
//start_routine:是个函数地址,线程启动后要执行的函数
//arg:传给线程启动函数的参数
//返回值:成功返回0;失败返回错误码
错误检查:
因为线程相关函数是由第三方库实现的,所以线程的地址空间也包含在动态库内:
这里的线程ID其实是一个地址,指向了存储线程信息那一块地址的起始位置。通过对指针的解引用就可以找到存储线程信息的结构体struct_pthread。
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
pthread_exit函数:
//功能:线程终止
//原型
void pthread_exit(void *value_ptr);
//参数
//value_ptr:value_ptr不要指向一个局部变量。
//返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel函数:
//功能:取消一个执行中的线程
//原型
int pthread_cancel(pthread_t thread);
//参数
//thread:线程ID
//返回值:成功返回0;失败返回错误码
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。基于以上两点,需要进行线程等待。
//功能:等待线程结束
//原型
int pthread_join(pthread_t thread, void **value_ptr);
//参数
//thread:线程ID
//value_ptr:它指向一个指针,后者指向线程的返回值
//返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。这就是分离线程
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
//分离目标线程
pthread_detach(pthread_t thread);
//分离线程自身
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
互斥量: 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
先来看下面这个抢票代码:
#include
#include
#include
int tickets = 1000;
void* TicketsGarb(void* msg) {
int num = (int)msg;
while (1) {
//当票数不为零时,才进行抢票操作,否则就退出
if (Tickets > 0) {
usleep(1000);
printf("num %d get a ticket, remains %d\n", num, --tickets);
}
else {
break;
}
}
printf("num %d quit!\n", num);
}
int main() {
pthread_t tid[5];
for (int i = 0; i < 5; ++i) {
pthread_create(tid + i, NULL, TicketsGarb, (void*)i);
}
for (int i = 0; i < 5; ++i) {
pthread_join(tid[i]);
}
return 0;
}
结果如下:
可以发现,虽然我们设置了if条件,但是依然出现了错误的结果,为什么呢?
因为if 语句判断条件为真以后,代码可以并发的切换到其他线程,usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段,–ticket 操作本身就不是一个原子操作。
–操作并不是原子操作,而是对应三条汇编指令:
那么如何解决这样的问题呢?
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量初始化:
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
//参数:
//mutex:要初始化的互斥量
//attr:NULL
销毁互斥量:
//销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//进程加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
//进程解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
销毁互斥量时应当注意:
加上互斥量后,再来看一下我们的抢票代码:
#include
#include
#include
int tickets = 1000;
pthread_mutex_t lock;
void* TicketsGarb(void* msg) {
int num = (int)msg;
while (1) {
pthread_mutex_lock(&lock);
if (Tickets > 0) {
usleep(1000);
printf("num %d get a ticket, remains %d\n", num, --tickets);
pthread_mutex_unlock(&lock);
}
else {
pthread_mutex_unlock(&lock);
break;
}
}
printf("num %d quit!\n", num);
}
int main() {
pthread_t tid[5];
pthread_mutex_init(&lock, NULL);
for (int i = 0; i < 5; ++i) {
pthread_create(tid + i, NULL, TicketsGarb, (void*)i);
}
for (int i = 0; i < 5; ++i) {
pthread_join(tid[i]);
}
pthread_mutex_destroy(&lock);
return 0;
}
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下:
其实,互斥量就像一个令牌一样,只有令牌持有者才能进行临界资源的访问,因为令牌只有一个,当其访问完成后,就要归还令牌。
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
可重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况:
常见线程安全的情况:
常见不可重入的情况:
常见可重入的情况:
可重入与线程安全联系:
可重入与线程安全区别:
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件:
想要避免死锁,就要破坏形成死锁的条件:
避免死锁的算法有死锁检测算法,银行家算法。
条件变量:
同步概念与竞态条件:
条件变量函数的初始化:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
//参数:
//cond:要初始化的条件变量
//attr:NULL
条件变量函数的销毁:
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足,进入等待:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
//参数:
//cond:要在这个条件变量上等待
//mutex:互斥量
唤醒等待:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
互斥量的作用:
条件变量使用方式:
//等待条件代码:
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
//否则修改条件
pthread_mutex_unlock(&mutex);
//给条件发送信号代码:
pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
假设有两个进程(或线程)A、B和一个固定大小的缓冲区,A进程生产数据放入缓冲区,B进程从缓冲区中取出数据进行计算,这就是一个简单的生产者-消费者模型。这里的A进程相当于生产者,B进程相当于消费者。
生产者消费者模式就是通过一个缓冲区来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
其优势在于:解耦、支持并发、支持忙闲不均
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
初始化信号量:
#include
int sem_init(sem_t *sem, int pshared, unsigned int value);
//参数:
//pshared:0表示线程间共享,非零表示进程间共享
//value:信号量初始值
销毁信号量:
int sem_destroy(sem_t *sem);
等待信号量:
//功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
释放信号量:
//功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
悲观锁: 在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁: 每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作: 当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁: 和挂起等待锁稍有不同,自旋锁会不断地询问信息。