1、多进程 & 多线程
思想:
父进程(或主线程)负责监控,并接收客户连接(accept)
fork创建子进程(或函数线程),与一个客户端交互(recv , send)
注意:
多进程:
1.父子进程之间共享文件描述符,所以父进程不需要将接收连接的文件描述符传递给子进程;
2.父进程要关闭连接的文件描述符;
原因:
1.父进程不关闭文件描述符,则后续创建的子进程会将所有的文件描述符继承下来;
2.父进程不关闭文件描述符,则后续的连接的文件描述符不断增大,连接的客户端
的数量就受一个进程最多打开的文件的限制;
缺陷:
1.创建一个进程,为一个客户端交互完成后,线程也随之结束,会造成服务器系统负担;
2.如果客户端很多,则服务器创建的子进程也会很多,并且大部分子进程都会阻塞在recv操作;
多线程:
1.主线程接收连接,连接的文件描述符必须通过 创建函数线程时值 传递给函数线程;
2.主线程 不能 关闭文件描述符;
多线程特点:
1.创建线程资源消耗相对较小;
2.线程之间数据共享更容易;
3.线程结束释放资源比较少;
与多进程编程相比:
1.创建多进程会消耗大量的系统资源;
2.如果子进程在很短的时间内结束,系统的负担会加重;
2、线程池 &进程池
池:初始时,申请比刚开始要使用的资源 大的多 的资源空间,
接下来使用时,直接从池中获取资源;
进程池
原因:
1.系统能够创建的进程或则一个进程中能够创建的线程都是有限的;
2.为一个客户端连接创建一个进程或线程,客户端断开则销毁 是不划算的;
所以,我们用线程池来改善问题。
思想:
服务器启动则创建n(固定值,有限个)个子进程或函数线程,
将这n个子进程或函数线程用池管理起来,服务器终止时销毁,
当有客户端连接时,从线程池中分配一个子进程或线程为其服务,
服务完成以后以后,服务器就将子进程或线程又放回池中,
继续等待分配下一个客户端;
线程池的实现:
1.主线程 执行先创建 3 条线程;
2.主线程 等待客户连接,3 条函数线程因为 信号量的 P 操作阻塞运行;
3.主线程接收到客户端连接后,通过 信号量 的 V 操作 通知一个函数线程和客户端通讯;
全局数组作为 等待函数线程处理的 文件描述符 的等待队列
线程池 & 线程池 相比多线程的优势
1.创建的进程或者线程是有限的,服务器的系统代价比较小,一般不会达到系统限制的值;
2.服务器不需要频繁的创建、销毁进程或线程,只在服务器启动时创建,结束时销毁;
3.创建的进程或者线程不是为一个客户端服务,可以串行为多个客户端服务;
4.客户端连接上以后,不需要再去创建进程或线程,
只需要分配进程池或线程池中的进程或线程,对客户端的速度就能快一些;
3、线程编程
1.线程的概念
(程序:磁盘上存储的二进制可执行文件;)
(进程的概念:运行(加载到内存)中的程序;一组有序指令+数据+资源的集合;)
线程:进程内部的一条执行序列(执行流),一个进程可以包含多条线程,
至少会有一条线程(main函数所代表执行序列,主线程)
通过函数库创建线程 ---> 函数线程,一个进程中的所有线程都是并发执行的;
2.线程与进程的区别
1、线程是执行的最小单位、CPU调度的最小单位;进程是资源分配的最小单位;
2、一个进程中可以包含多条线程,进程是独立的执行个体;线程是进程内部的执行序列;
3、进程切换效率低,代价大;线程切换效率高,代价小(指令小)
{ 协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的;
优点如下:
1.协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量
2.单线程内就可以实现并发的效果,最大限度地利用cpu
缺点如下:
1.协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,
每个进程内开启多个线程,每个线程内开启协程;
2.协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程;
总结协程特点:
1.必须在只有一个单线程里实现并发
2.修改共享数据不需加锁
3.用户程序里自己保存多个控制流的上下文栈 }
3.线程的实现方式
用户级、内核级、混合模式
用户级:线程的创建、销毁、管理都在用户空间完成, 内核只会识别为一个进程,一条线程;
特点:
1、灵活性,操作系统不知道线程存在,在任何平台上都可以运行;不用修改操作系统,
容易实现;切换效率高,不需要陷入内核;
2、内核实现简单,但编程复杂,线程的创建、调度、管理都需要用户程序自己完成;
3、如果一个线程阻塞,整个进程都会阻塞;不能使用对称多处理器;
内核级:线程的创建、销毁、管理由操作系统内核完成;
内核线程使得用户编程简单,但是每次切换都得陷入内核
(从用户态切换到内核态),所以效率较低;
混合模式:即一部分以用户级线程创建,一部分由内核创建,是一个多对多的关系;
结合用户级和内核级的优点;
4.线程库的使用 --> 线程创建
线程库包含在头文件 pthread.h 中
线程的创建函数:
int pthread_create(pthread_t *id, pthread_attr_t *attr,
(void*)(*pthread_fun(void*)), void *arg);
返回值:成功返回0,失败返回错误码;
id : 用于获取系统创建的线程的ID值;
attr: 线程的属性,默认属性 NULL;
pthread_fun: 线程函数,指定新建线程的执行序列;
arg: 传递给线程函数的参数;
测试结论:
1、pthread_create 创建一个新的线程,新线程执行的指令序列是pthread_fun指针指向的函数;
2、进程中的线程 并发执行;
5.一个进程中的所有线程间的数据共享:
全局变量 .data 共享
局部变量 .stack 不共享
堆区变量 .heap 共享
文件描述符 fd 共享
6.创建线程时传参
pthread_create(); 创建线程是给线程函数传参的方式:
1、值传递 : 值最大4个字节,void* 只有4个字节;
2、地址传递:实现了线程间的栈区数据共享;
主线程结束时:默认调用exit函数,结束的是进程;
线程结束的函数:
int pthread_exit(void *reval); //reval:设置线程的退出状态;
等待线程结束函数/获取线程退出状态:
int pthread_join(pthread_t id, void **getval);
//会阻塞运行;并获取线程退出的信息
waitpid(pid_t pid, );
主动结束一个线程:int pthread_cancel(pthread_t id);
7.线程同步
实现线程同步的方法:信号量、互斥锁、读写锁、条件变量
互斥锁:只能在线程之间使用的一种控制临界资源访问的机制;
如果一个线程要访问临界资源,则必须先加锁,用完之后解锁;
一个锁只有两种状态:加锁 解锁
0 1
#include<pthread.h>
pthread_mutex_t mutex; //全局变量
int pthread_mutex_init(pthread_mutex_t *mutex_t, pthread_mutexattr_t *attr);
int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁 会阻塞
int pthread_mutex_trylock(pthread_mutex_t *mutex); //尝试加锁 不会阻塞
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
int pthread_mutex_destroy(pthread_mutex_t *mutex); //销毁锁
信号量:类似于计数器,记录临界资源的数量;
#include<semaphore.h>
sem_t sem;
int sem_init(sem_t *sem, int shared, int val);
shared:信号量是否可以在进程间共享,Linux不支持;
val:设置的信号量的初始值;
int sem_wait(sem_t *sem); //P操作 wait等待(sem-1)
int sem_post(sem_t *sem); //V操作(sem+1)
int sem_destroy(sem_t *sem); //销毁信号量
条件变量(读写锁 自旋锁)
条件变量:
条件变量是线程可用的另一种同步机制,给多个线程提供了一个会合的场所
条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生
条件变量是线程中的东西,就是等待某一条件的发生,和信号一样;
条件变量的使用:
条件变量要与互斥量一起使用,条件本身是由互斥量保护的,
线程在改变条件状态之前必须首先锁住互斥量,
其他线程在获得互斥量之前不会察觉到这种改变,
因为互斥量必须在锁定以后才能计算条件;
条件变量的作用:
使用条件变量可以以原子方式阻塞线程,直到某个特定条件为真为止。
条件变量始终与互斥锁一起使用,对条件的测试是在互斥锁(互斥)的保护下进行的。
如果条件为假,线程通常会基于条件变量阻塞,并以原子方式释放等待条件变化的互斥锁。
如果另一个线程更改了条件,该线程可能会向相关的条件变量发出信号,
从而使一个或多个等待的线程执行以下操作:1.唤醒 2.再次获取互斥锁 3.重新评估条件;
读写锁:
一个资源可以被多个线程同时读,或者被一个线程写,但是不能同时存在读和写线程;
读写锁作用:
读写锁能够保证读取数据的 严格实时性,如果不需要这种 严格实时性,那么不需要加读写锁;
自旋锁:
是一种用于保护多线程共享资源的锁,与一般互斥锁(mutex)不同之处在于
当自旋锁尝试获取锁时以忙等待(busy waiting)的形式不断地循环检查锁是否可用。
自旋锁的作用:
在多CPU的环境中,对持有锁较短的程序来说,
使用自旋锁代替一般的互斥锁往往能够提高程序的性能。
8.保证线程安全的库函数
多线程环境下,操作共享的资源时,可能会发生一些问题 --> 执行的结果不确定;
可重入函数 strtok_r
cahr *strtok(char *buff, char *flag);
char *strtok_r(char *buff, char *flag, char **rtptr);
什么是线程安全?
线程主要由控制流程和资源使用两部分构成,因此一个不得不面对的问题就是对共享资源的访问。
为了确保资源得到正确的使用,开发人员在设计编写程序时需要考虑避免竞争条件和死锁,
需要更多地考虑使用线程互斥变量;
因此在编写线程安全函数时,要注意两点:
1.减少对临界资源的依赖,尽量避免访问全局变量,静态变量或其它共享资源,
如果必须要使用共享资源,所有使用到的地方必须要进行互斥锁 (Mutex) 保护;
2.线程安全的函数所调用到的函数也应该是线程安全的,如果所调用的函数不是线程安全的,
那么这些函数也必须被互斥锁 (Mutex) 保护;
9.多线程下的fork使用及锁的继承问题
1、如果多线程环境下,一个线程调用fork创建子进程,
子进程仅仅会将调用fork的那个线程启动,其他线程并不会启动;
2、多线程环境下调用fork创建的子进程继承父进程的锁;
子进程会继承父进程的锁,包括其状态;
注册函数:
int pthread_atfork(void(*parpare)(void), void(*parent)(void),
void(*child)(void)); //fork之前调用
fork调用初始,先调用parpare函数,其作用是:给所有的锁加锁,
如果有锁处于加锁状态,则fork函数会被阻塞,等待解锁;
parent和child函数在fork执行完成之后,分别在父进程空间和子进程空间调用,
其作用是:对所有的锁解锁;
伪代码:
mutex
void pthread_fun(void *arg)
{
pthread_mutex_lock(&mutex);
sleep(3);
unlock();
}
int main()
{
.....
sleep(1); //保证函数线程能够加锁;
pit_t pid = fork(); //fork执行时,mutex是函数线程加锁状态;
if(pid == 0)
{
lock(&mutex);
printf();
}
else
{
lock(&mutex);
printf();
}
}