线程的那些事
前言:这篇文章主要小结下linux下多线程的知识点,并且有一些多线程编程中的拓展概念,以及c语言编写线程池的思路
- linux线程简介
- 线程的私有数据和公有数据
- 线程的上下文切换
- 创建线程
- 线程终止
- 互斥量
- 条件变量
- 多线程概念简单拓展
- 竟态条件
- 并发与并行
- 同步与互斥
- volatile
- CAS(compare and swap)
- 指令重排和内存屏障
- c语言实现线程池思想
linux线程简介
系统中的进程在某些情况下并不能满足我们的需求,比如一个操作系统创建进程的数量非常有限,进程的上下文切换相对来说比较慢,进程间通信(IPC)比较麻烦…等等
所以系统中引入线程这一概念,看看linux下的线程linux实现线程的机制和其他操作系统有很大区别,从内核的角度,它并没有线程的概念,所有的线程都被当作进程来实现(轻量级进程) 。线程仅仅被视为和其他进程共享某些资源的进程。
linux下线程的创建和进程的创建类似,只不过在调用clone()系统调用的时候要传递一些参数标志来指明需要共享的资源。
线程调用的clone()
clone(CLON_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
clone函数中的参数就限定了线程,父子共享内存空间,文件系统资源,文件描述符和信号处理程序等等。
进程创建fork()
clone(SIGCHLD, 0);
进程创建vfork()
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);内核线程:
有时系统要执行一些后台操作,这些任务可以通过内核线程完成,独立运行在内核空间的标注进程,最重要的一点它们只运行在内核空间从不切换到用户空间去。曾经linux异步IO其中一种实现方式就是通过内核线程来操作,用户发起一个任务,立即返回,有内核线程帮助用户完成任务,完成后通知用户。
线程的私有数据和共有数据
每个线程都有自己的私有数据,其中包含执行环境的必须信息,线程ID,一组寄存器值,栈,调度优先级,信号屏蔽字,errno变量,以及线程的私有数据(TSD,一键多值技术,可以被多个函数访问,但是对其他线程是屏蔽的)。
一个进程信息对所有线程都是共享的,包括可执行程序的代码,程序的全局内存和堆内存,栈以及文件描述符。
例子(看一下进程内存映射数据分布):
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <pthread.h>
void *thread_func(void *args)
{
printf("tid: %u pid: %u thread id: %u\n",
getpid(), syscall(224), pthread_self());
while(1)
{
sleep(10);
}
}
int main(int argc, char *argv[])
{
pthread_t thread;
int count = 0;
while(pthread_create(&thread, NULL, thread_func, NULL) == 0)
{
sleep(5);
++count;
}
return EXIT_SUCCESS;
}
创建了8个线程
此进程的内存映射
上图为/proc/(进程ID)/maps文件中的内容,我们可以看见进程为创建的线程分布的私有栈(保存线程栈变量以及cpu切换暂存线程数据等),还有最下面的stack,这个就是进程的栈了。理解了上图我们就能理解线程的私有数据和共有数据在进程中的大致分布。
上下文切换
线程的应用广泛离不开它的轻量,轻也体现在线程的上下文切换。
线程和进程的上下文切换最主要的区别是线程的切换虚拟内存空间是相同的,但进程是不同的,这两种切换都会将寄存器变量切换出去(线程私有寄存器变量),会有性能消耗。
另外进程中上下文切换会打乱处理器的缓存机制,简单的来讲,一旦切换出去上下文,处理器中所有已经缓存的内存地址和数据等都作废了,尤其是进程切换会将整个虚拟内存空间切换出去,刷新了许多缓存,导致效率降低。但是在线程切换中就不会出现这个问题。
创建线程
参数分别为线程id,线程属性设置,线程执行函数,线程执行函数参数。
pthread_attr_t是一个结构体,一般我们使用默认的属性。
编译时要连接线程库
注意:新线程要获取自己的pid只能使用pthread_self(),因为可能在pthread_create()函数未创建完成时,线程已经开始运行。我们使用线程自己提供的api获取线程id比较安全
线程终止
pthread_join和pthread_detach函数
pthread_join:
pthread_join参数为线程的id以及一个获得等待线程执行完成的返回值的一个二级指针。
pthread_join有两个功能:
1.主线程来等待子线程结束,如果线程未调用pthread_detach,则必须调用pthread_join来等待线程,如果没有等待,一方面主线程会不等待子线程从而提前结束,那么子线程可能会没机会运行,其次则会产生僵尸线程,与僵尸进程的概念类似,除了浪费系统资源外,如果存在大量僵尸线程,我们将可能无法创建新的线程。
2.线程之间是平等的,也就是说我们除了主线程等待子线程用pthread_join外,我们还可以连接任意两两线程,一个线程调用pthread_exit()并且返回一个数值,另外一个线程调用pthread_join()来接受返回值并且等待另外线程结束。pthread_detach:
pthread_detach函数参数仅为线程id,意义为将指定id的线程变为后台运行,由系统来为它回收资源。
互斥量(互斥锁)
多个线程同时访问一个资源可能会出现不能预料到的结果,此时我们可以通过互斥量来保证线程同步访问资源。
当我们对一个临界资源(某时刻只能有一个线程或进程访问的资源)进行访问时,必须先拿到互斥锁,否则阻塞在锁上,当我们抢到互斥锁时就可以访问资源,访问完成时释放锁供其他进程或线程得到并继续交替访问临界资源。
初始化: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);
//头文件:
#include <pthread.h>
//函数原型:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意以下几点:
1.
1.避免线程对多个共享资源的互斥量进行访问而导致的死锁,我们应该对互斥锁进行设定层级关系,也就是说加锁顺序必须确定
线程对多个共享资源的互斥量进行访问
如果我们强制设定加锁顺序为mutex1后mutex2,也就是说mutex1为一级锁,mutex2为二级锁,则不会出现上面的情况。
2.
递归互斥锁,也称为可重入锁,简单来讲就是我们可以重复对递归互斥锁加锁而不会出现普通互斥锁未释放锁就加锁的死锁情况。
递归互斥锁适用于如下场景
func_one()
{
lock_mutex;
dosomething();
unlock_mutex;
}
func_two()
{
lock_mutex();
dosomething();
func_one();
unlock_mutex;
}
3.
pthread_mutex_errorcheck
对此类互斥锁错误使用会进行报错。
条件变量
互斥量防止多个线程同时访问某一共享变量,条件变量允许一个线程就某个共享变量的状态变化通知其他线程,并让其他线程等待。
默认我们使用互斥锁时,如果锁此时被其他线程持有,那么后来的线程就默认阻塞,这样效率是非常低的,现在我们在想让线程获取互斥锁的地方调用条件变量的wait函数等待条件变量的条件变化从而通知我们,我们通过条件变量来避免线程阻塞,当持有锁的线程释放锁时,线程被通知。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
PTHREAD_COND_INITIALIZER是一个宏定义的默认结构体成员,上面代码是给条件变量设置默认的属性。我们也可以通过init来设置条件变量的属性。
//初始化条件变量,attr是条件变量的属性,比如更改条件等。
pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
//销毁条件变量,必须在没有使用时销毁
pthread_cond_destroy(pthread_cond_t *cond);
//阻塞某一线程,直至收到条件变量的cond的通知
//补充:pthread_cond_wait()内部会有一个先加锁后解锁的过程
pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t *mutex);
//带休眠时间的条件变量
pthread_cond_timewait()
//通知等待的任意一个线程
pthread_cond_signal(pthread_cond_t *cond);
//通知所有等待互斥量的线程
pthread_cond_broadcase(pthread_cond_t *cond);
多线程概念简单拓展
竟态条件
从多线程的角度来说就是多个线程对同一块资源进行访问时,最终结果取决于线程执行的顺序,但是线程执行的顺序不固定,每一执行的结果也就是不确定的。
并发与并行
并发:同一时间,同时发生的事情
并行:同一时间,同时在做的事情
简单的来说就并发是指同一时间我们能接受多少,并行是同一时间我们能处理多少。
举个例子:老妈在做饭,让我帮忙干活,短短的一分钟她说了四件事,去超市买盐,去收衣服,去扫地,并嘱咐我喝完桌子的饭, 没办法我只能答应- -,这就是并发,我不会分身术,我只有两只手,我可以边扫地边喝桌子上的饭,这是并行。
再说个socket的例子吧,都知道epoll IO复用解决了C10K问题,它可以同时接受10000左右的连接数,这指的是并发, 但是一台计算机只有两个CPU,
同时只能有几个线程在上面跑,这是并行。
同步与互斥
互斥:指的是同一件事或者同一个东西,在同一时刻在只允许一个用户在使用或者在做。
同步:在互斥的基础上有序的进行访问。注意同步并没有说什么同一时刻之类的。
volatile
线程间同步的一种手段,但是并不保证能正确得到结果,volatile保证每次读取数据都是从内存中最新的而不是缓存中。
缺点:
volatile关键字能保证可见性,但不能保证原子性(不可在被分割),可见性只能保证每次读取的是最新的值,但是volatile无法保证对变量等操作的原子性。
例子:
线程1对变量进行递增操作,线程1读取了变量i的初始值为10,然后时间片到了,轮到线程2,线程2对变量进行自增操作,线程2也去读取i的原始值10,由于线程1只是对i进行读取并没有修改,线程2修改后写入主存11,此时轮到线程1执行,线程1修改的还是初始值10,然后写回内存中修改后的11,等于两次操作一次修改。
CAS
CAS(compare and swap)是一种策略,当A线程访问一个变量时,先取出旧值并保存,然后保存变化后的新值,此时再次比较旧值和内存中的值是否一致(一致说明没有被其他线程修改),如果一致则更新值。
指令重排和内存屏障
指令重排:一般处理器不会按照我们代码的顺序来执行而是会重新排序代码形成的指令,按照最顺畅最快的方式来执行。
比如代码有三件事情要做,但是第一和第三件可以连着做,那么cpu就会重排指令变为一三二,为了达到更快的执行速度。内存屏障:指令重排在多线程情况下可能会产生不确定的结果
也被成为“内存栅栏”, 简单的讲就是严格的限定指令的执行顺序,也就是禁止特定类型的处理器重排。也是为了避免多线程并发受到影响。
c语言实现线程池的思想
没写过线程池的童鞋都会有一些误解,所以借着这篇博客说一下c语言实现线程池的思想。
一般对线程池纠结的地方会有如下。
线程是如何保存在“池”中的?
线程是如何被调度的?
线程如何被借用执行任务然后在归还?归还过程是怎么样的?
线程池要执行的任务又是如何保存的?线程池一般是一个队列,元素为创建的线程的id,我们通过线程id来控制线程,线程池要执行的任务也是保存在一个队列或链表中,里面存储的是函数指针,也称为callback。
那么线程池一般维持一个线程池结构体,内部成员包含:
互斥锁(保证任务存取队列每时每刻只有一个线程操作,避免竟态条件)
条件变量(当任务队列从空变为非空时,要通过条件变量来唤醒)
线程id链表(也就是存放线程id的队列或链表,管理所有线程)
任务队列(存放callback函数指针)
线程池大小(capacity容量)
任务队列大小(要判断当前任务队列是否为空)
毁坏标记flag(如果想要销毁线程池需要有毁坏标记)
任务结构体成员就包括:
callback函数指针(调用执行任务)
callback函数参数arg(传递给callback的参数)
关键点:
其实所有线程一开始创建完成后执行的线程函数内都是一个while(1)死循环,所有的线程都会去抢任务队列的锁,抢到了则执行任务释放锁,每次抢任务时会检查任务队列的大小,如果为0则线程阻塞,当向任务队列中添加任务时,我们通过条件变量的同步来唤醒阻塞的线程。抢不到的则阻塞在锁上,并没有所想的那种线程执行完成后归还到“池”里需要则调用再次继续取出线程的过程。线程执行完继续抢任务(实际上是锁),抢不到就阻塞。抢到了就执行,没有所谓的归还过程。while(1)循环里会存在检查flag标记,如果标记被修改,则销毁线程。
完