一、线程
了解线程间同步方法之前,还是要弄清楚线程是什么。
1、线程概念
线程是进程内部的一条执行路径或者序列,是CPU调度的基本单位。
进程是一个正在运行的程序,是资源分配的基本单位。
2、操作系统中的线程
在操作系统中将线程的实现分为了三类
- 用户级线程:创建开销小,线程库自己管理,不需要内核管理,无法使用多个处理器,最多只能做到并发
- 内核级线程:创建开销大,由内核直接管理,可以使用多个处理器,能够实现真正意义是哪个的并行
- 混合方式
内核线程和普通的进程间的区别:内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL),它们只在内核空间运行,从来不会切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。
3、Linux中的线程
Linux中的线程实现的就更独特了。**从内核角度来说,其实并没有线程这个概念,Linux把所有的线程都当做进程来实现。**内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的PCB。所以在内核中,它看起来就像是一个普通的进程(只是该进程和其他一些进程共享某些资源,如地址空间)。
如果进程内部只有一条执行路径时,PID也就是线程的ID。
如图,进程thread的进程ID和线程ID是相同的。
如果进程内部有多条线程时,每个线程也会有自己的ID,就是LWP。
ps -ef可以查看当前所有进程的状态,但是查不到线程的PID。所以ps查看线程PID的参数是 -L
top -H 用来查看线程对资源的使用情况。
4、使用线程的优缺点
优点
- 可以让程序看起来可以做多件很有用的事情
- 可以改善程序执行的性能
- 多个线程需要的资源远小于多个进程
缺点
多线程使得进程内部的执行路径变成了多条,并且这多条执行路径在并发运行。那么程序的执行具有一定的不稳定性,每次执行的结果可能都会不同,因为程序交替执行的顺序和时机不同。其次,使得某些资源的访问出现了竞争,问题变得困难,需要资源进行同步。这会使得程序的可靠性和稳定性降低。
在进程中,多个线程共享该进程的资源,控制线程对资源的访问,使得彼此不会干涉,冲突。控制线程对临界资源的访问,保证同一时刻只有一个线程访问。
所以,线程的同步是非常重要的。线程的同步主要是为了更好的控制线程执行和访问代码临界区。常用到的同步方法有信号量和互斥锁,条件变量,读写锁。
内核中,线程同步用到的方法有自旋锁。
二、使用信号量进行同步
有两组接口函数都是用于信号量的,一组是被称为系统V信号量,常用于进程的同步。另外一组就是用于线程同步,也就是接下来的这组函数。
信号量被分为两种:
- 二进制信号量:只取0和1
- 计数信号量:>1
信号量一般常用来保护一段代码,使其一次只能被一个执行线程运行,这种情况下就需要用到二进制信号量。
如果是允许有限数目的线程执行一段指定的代码,这就需要用到计数信号量。
这一组接口函数如下:
#include<semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);//创建信号量
int sem_wait(sem_t *sem);//原子减1,也就是P操作
int sem_post(sem_t *sem);//原子加1,也就是V操作
int sem_destroy(sem_t *sem);//清理信号量拥有的所有资源
- 原子操作:如果两个线程企图给同一个信号量加1时,它们之间不会互相干扰,而不像如果两个程序同时对同一个文件进行读取、增加、写入操作时可能会引起冲突。原子操作会使得信号量的值总是被正确的加2,因为有两个线程试图改变它。
- sem_wait函数以原子操作的方式将信号量的值减1,但是它会等待直到信号量有个非零值才会开始减法操作。
使用一个二进制信号量进行同步
#include<stdlib.h>
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<assert.h>
#include<semaphore.h>
#include<pthread.h>
//先定义一个信号量
sem_t sem;
void *thread_fun(void *arg)
{
int i = 0;
for(;i<5;i++)
{
sem_wait(&sem);//原子减1
write(1,"B",1);
sleep(1);
sem_post(&sem);//原子加1
}
}
int main()
{
pthread_t id;
sem_init(&sem,0,1);
pthread_create(&id,NULL,thread_fun,NULL);
int i = 0;
for(;i<5;i++)
{
sem_wait(&sem);//原子减1
write(1,"A",1);
sleep(1);
sem_post(&sem);//原子加1
}
pthread_join(id,NULL);
sem_destroy(&sem);//destroy signal
exit(0);
}
使用多个二进制信号量进行同步
#include<stdlib.h>
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<assert.h>
#include<semaphore.h>
#include<pthread.h>
sem_t sem_a,sem_b,sem_c;
void *thread_fun2(void *arg)
{
int i = 0;
for(;i<5;++i)
{
sem_wait(&sem_c);
write(1,"C",1);
sleep(1);
sem_post(&sem_a);
}
}
void *thread_fun1(void *arg)
{
int i = 0;
for(;i<5;i++)
{
sem_wait(&sem_b);
write(1,"B",1);
sleep(1);
sem_post(&sem_c);
}
}
int main()
{
pthread_t id1;
pthread_t id2;
// pthread_t id3;
sem_init(&sem_a,0,1);
sem_init(&sem_b,0,0);
sem_init(&sem_c,0,0);
pthread_create(&id1,NULL,thread_fun1,NULL);
pthread_create(&id2,NULL,thread_fun2,NULL);
int i = 0;
for(;i<5;i++)
{
sem_wait(&sem_a);
write(1,"A",1);//begin
sleep(1);
sem_post(&sem_b);
}
pthread_join(id2,NULL);
sem_destroy(&sem_a);//destroy signal
sem_destroy(&sem_b);
sem_destroy(&sem_c);
exit(0);
}
同步执行的结果就是顺序打印ABC
三、使用互斥锁进行同步
多线程程序中用来同步访问的方法还有一种就是使用互斥锁(互斥量),使得每次只能有一个线程访问它。为了控制对关键代码的访问,必须在进入这段代码之前锁住一个互斥量,然后在完成操作之后进行解锁。
互斥锁的取值只能为1,相当于信号量中的二值信号量,只能用0和1来表示。
一组函数接口如下:
#include<pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
与其他函数一样,成功时返回0,失败时将返回错误代码,但这些函数并不设置errno,所以必须对函数的返回代码进行检查。
互斥锁声明的对象的类型为 pthread_mutex_t。
代码实现:
#include<stdlib.h>
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<assert.h>
#include<semaphore.h>
#include<pthread.h>
thread_mutex_t mutex;
void *thread_fun(void *arg)
{
int i = 0;
for(;i<5;i++)
{
pthread_mutex_lock(&mutex);
write(1,"B",1);
int n = rand() % 3;
sleep(n);
write(1,"B",1);
pthread_mutex_unlock(&mutex);
n = rand() % 3;
sleep(n);
}
}
int main()
{
pthread_t id;
pthread_mutex_init(&mutex,NULL);
pthread_create(&id,NULL,thread_fun,NULL);
int i = 0;
for(;i<5;i++)
{
pthread_mutex_lock(&mutex);
write(1,"A",1);//begin
int n = rand() % 3;
sleep(n);
write(1,"A",1);//end
pthread_mutex_unlock(&mutex);
n = rand() % 3;
sleep(n);
}
pthread_join(id,NULL);
pthread_mutex_destroy(&mutex);
exit(0);
}
四、内核同步-自旋锁
1、为什么需要锁
在现实生活中,临界区很多情况下不只在一个函数或者代码中。比如,如果我们要将当前数据结构中的数据一一移出,然后进行格式转换和数据解析,最后再把它加入到另一个数据结构中。整个执行过程必须是原子的,在数据被更新完毕前,不能有其它代码来读取这些数据。这样的话,原子操作就无法满足需求了。所以需要使用到一种更为复杂的同步方法——锁来提供保护。
2、自旋锁
**Linux内核中最常见的锁就是自旋锁(spin lock)。自旋锁最多只能被一个可执行线程持有,所以同一时刻只能有一个线程位于临界区。**如果一个执行线程想要去获得一个被争用的自旋锁,那么该线程就会一直进行忙循环——旋转——等待锁重新可用。如果锁未被争用,那么请求锁的线程就可以立刻得到它,继续执行。在任何时刻,自旋锁都可以防止多于一个的执行线程同时进入临界区。同一个锁可以用在多个位置。
一个被争用的自旋锁使得请求它的线程在等待锁重新可用时自旋,这种行为是自旋锁的要点。
Linux内核实现的自旋锁是不可递归的。如果你试图得到一个你正持有的锁,你必须自旋,等待自己释放。但要是你处于自旋忙等待中,就永远没有机会释放锁,就被锁死了。
自旋锁可以使用在中断处理程序中。
自旋锁使用注意事项
由于自旋锁最主要特性是不断反复循环测试自己是否可以获得锁:
- 临界区代码不要存在睡眠情况。主要因为发生睡眠无法预知睡眠多长时间,另外长时间睡眠,导致即将进入临界区的其他线程,长时间得不到自旋锁,而无休止的自旋,从而导致死锁的发生。所以临界区调用导致睡眠函数,不能选择自旋锁。
- 保证进入临界区的线程,不会发生抢占。因为进入临界区的线程持有自旋锁,所以本身也是不会发生抢占的。
- 临界区的代码,执行时间不能太长。因为如果其他线程想要进入临界区,可是长时间无法获得锁,就会一直自旋,导致过多消耗CPU资源。
- 选择自旋锁时,也要注意中断情况(上半部分中断(硬件中断)和下半部分中断(软中断),中断会抢占即中断到来时,打断目前临界区代码执行,转往执行中断代码),当中断要进入自旋锁保护临界区代码时,将导致线程与中断发生死锁可能。
3、信号量
Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时处理器就可以去处理别的事了。当有持有信号量的进程将信号量释放后,等待队列中的任务将被唤醒,并获得该信号量。
关于使用信号量的结论:
- 由于争用信号量的进程在等待锁重新变为可用时会睡眠,所以信号量适用于锁会被长时间占有的情况。
- 如果锁被短时间持有时,使用信号量就不太适宜了。因为睡眠,维护等待队列以及唤醒所花费的开销可能比锁被占用的全部时间还要长。
- 由于执行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获取信号量锁,因为在中断上下文中是不能进行调度的。
- 持有信号量的进程可以在持有信号量时去睡眠,因为当其他进程试图获得同一信号量时也不会因此而造成死锁,因为该进程也就睡眠了,而持有信号量的进程在睡眠后还是会继续执行的。
- 在占用信号量的同时不能占用自旋锁。因为在等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。
- 信号量可以同时允许任意数量的锁持有者,而自旋锁在一个时刻最多只允许一个任务持有它。信号量同时允许的持有者数量可以在声明信号量时指定。
- **信号量也分为二值信号量和计数信号量。**通常情况下,信号量和自旋锁一样,在一个时刻仅允许有一个锁持有者,这样通常使用二值信号量。计数信号量不能用来进行强制互斥,因为它允许多个线程同时访问临界区。相反,计数信号量用来对特定代码加以限制,内核中使用的机会不多。