由进程池衍生出来的知识点总结
2022-4-20分享
进程池->管道部分内容->epoll和select->socket套接字编程部分内容->tcp部分内容
进程池:
定义一个池子,在里面放上固定数量的进程,有需求来了,就拿一个池中的进程来处理任务,等到处理完毕,进程并不关闭,而是将进程再放回进程池中继续等待任务。如果有很多任务需要执行,池中的进程数量不够,任务就要等待之前的进程执行任务完毕归来,拿到空闲进程才能继续执行。也就是说,池中进程的数量是固定的,那么同一时间最多有固定数量的进程在运行。这样不会增加操作系统的调度难度,还节省了开闭进程的时间,也一定程度上能够实现并发效果。
进程相关部分内容:
进程是什么?
进程是操作系统资源分配的基本单位,用PCB描述、记录进程的信息。
- 进程相关的命令:
-
ps -elf 查看系统中的进程。ps 命令是一个采样的信息。
-
ps -aux 可以查看进程的CPU和内存的占用率。
-
echo $$打印当前bash的进程ID。
-
top命令 动态的显示进程的信息,展示的是系统中CPU占用率最高的20个进程。
-
kill命令,给进程发信号,使用方式:kill -信号的编号 进程ID。通过kill -l可以查看所有信号。
-
nice命令按照指定的优先级运行进程,renice命令可以修改进程的nice值,nice值的范围:-20-19。
- nice -n 可执行程序
- renice -n 指定的nice值 -p 进程ID
-
通过jobs命令可以看到当前会话下面的后台作业,每个作业都有一个编号。可以通过fg+作业编号把后台运行的作业拉回到前台。拉回到前台之后,就可以通过控制终端跟前台进程交互。
常见函数:
// 1. system 函数
#include <stdlib.h>
int system(const char *command);
system(cmd): 创建新进程并使用 shell 脚本执行传入的命令 cmd
// 2. fork 函数
#include <unistd.h>
pid_t fork(void);
// fork(): 创建子进程。子进程返回值为 0, 父进程返回子进程的 pid
进程池代码:
#include "process_pool.h"
typedef struct{
pid_t pid;//子进程的pid
int pipeFd;//子进程的管道对端
short busy;//用来标识子进程是否忙碌,0代表非忙碌,1代表忙碌
}process_data_t;
// 传参 ip地址,端口号,进程数
int main(int argc,char* argv[])
{
if(argc!=4)
{
printf("./process_pool_server ip port process_num\n");
return -1;
}
int processNum=atoi(argv[3]);//得到进程数
//分配保存子进程结构体数组的空间
process_data_t *pData=(process_data_t*)calloc(processNum,sizeof(process_data_t));
//1.创建子进程
makeChild(pData,processNum);
int i;
//创建子进程,并初始化主数据结构
int makeChild(process_data_t *p,int processNum)
{
int i;
pid_t pid;
int fds[2];//管道,用于父子进程通信
int ret;
for(i=0;i<processNum;i++)
{
/*1. 这对套接字可以用于全双工通信,每一个套接字既可以读也可以写。例如,可以往sv[0]中写,从sv[1]中读;或者从sv[1]中写,从sv[0]中读;
2. 如果往一个套接字(如sv[0])中写入后,再从该套接字读时会阻塞,只能在另一个套接字中(sv[1])上读成功;
3. 读、写操作可以位于同一个进程,也可以分别位于不同的进程,如父子进程。如果是父子进程时,一般会功能分离,一个进程用来读,一个用来写。因为文件描述副sv[0]和sv[1]是进程共享的,所以读的进程要关闭写描述符, 反之,写的进程关闭读描述符。
*/
ret=socketpair(AF_LOCAL,SOCK_STREAM,0,fds);//第一个参数就固定,第二个表示基于tcp/udp,如果是udp的话就是sock_DGRAM,第三个就是0,
ERROR_CHECK(ret,-1,"socketpair");
pid=fork();
//子进程创建好之后,执行childHandle
if(0==pid)
{
close(fds[1]);
childHandle(fds[0]);
}
//父进程,创建子进程之后,记录子进程的信息
close(fds[0]);//关闭管道一端
p[i].pid=pid;//子进程pid
p[i].pipeFd=fds[1];//存储每个子进程的管道对端
p[i].busy=0;
}
return 0;
}
int childHandle(int pipeFd)
{
int newFd;
char finishFlag;
while(1)
{
recvFd(pipeFd,&newFd);//接收任务,没有任务时,子进程会睡觉
tranFile(newFd);//给客户端发文件
//关闭连接,服务器传递newFd给子进程时已经close一次,
//所以此时newFd的引用计数为1,close之后就会断开连接
close(newFd);
write(pipeFd,&finishFlag,1);//子进程通知父进程完成任务啦
}
}
管道/信号量:
管道的分类
1. 标准流管道 FILE * popen(cmd,mode);(了解)
2. 无名管道(熟练掌握)
1. 无名管道的特点
1. 只能用于亲缘关系的进程之间
2. 半双工(具有固定读端pipefd[0]和写端pipefd[1])
int pipe(int pipefd[2]);//只适用于存在亲缘关系的进程之间进行通信
3. 存在内存中,进程结束就会消失。
4. 管道中数据是流式的,消息之间没有边界。(重要)
5. 先关闭管道写端,另一端读管道,read不会阻塞,返回值为0
2. 先关闭管道的读端,另一端写管道,写管道的进程会收到SIGPIPE信号。
3. 命名管道 (掌握)
1. 命名管道的特点
1. 可以用于非亲缘关系的进程之间
2. 存在于磁盘上,是一个磁盘上的文件。c
4. unlink函数
1. 可以删除指定文件
int unlink(const char *path);//删除包括管道文件在内的所有文件
int mkfifo(const char *pathname, mode_t mode);//创建有名管道文件
int rename(const char *oldpath, const char *newpath);//移动或重命名文件
int link(const char *oldpath, const char *newpath); //newpath不能是目录
进程池代码:
#include "process_pool.h"
typedef struct{
pid_t pid;//子进程的pid
int pipeFd;//子进程的管道对端
short busy;//用来标识子进程是否忙碌,0代表非忙碌,1代表忙碌
}process_data_t;
// 传参 ip地址,端口号,进程数
int main(int argc,char* argv[])
{
if(argc!=4)
{
printf("./process_pool_server ip port process_num\n");
return -1;
}
int processNum=atoi(argv[3]);//得到进程数
//分配保存子进程结构体数组的空间
process_data_t *pData=(process_data_t*)calloc(processNum,sizeof(process_data_t));
//1.创建子进程
makeChild(pData,processNum);
int i;
//2.初始化TCP
int socketFd;
tcpInit(&socketFd,argv[1],argv[2]);//初始化socket并开启监听
// int tcpInit(int *sfd,char* ip,char* port)
// {
// int socketFd=socket(AF_INET,SOCK_STREAM,0);
// ERROR_CHECK(socketFd,-1,"socket");
// struct sockaddr_in serAddr;
// bzero(&serAddr,sizeof(serAddr));
// serAddr.sin_family=AF_INET;
// serAddr.sin_port=htons(atoi(port));
// serAddr.sin_addr.s_addr=inet_addr(ip);
// int ret;
// ret=bind(socketFd,(struct sockaddr*)&serAddr,sizeof(serAddr));
// ERROR_CHECK(ret,-1,"bind");
// listen(socketFd,10);
// *sfd=socketFd;
// return 0;
// }
//3.创建epoll,监听socketFd和管道
int epfd=epoll_create(1);
struct epoll_event *evs;
evs=(struct epoll_event*)calloc(processNum+1,sizeof(struct epoll_event));
//把socketFd添加到epoll中监听
epollInAdd(epfd,socketFd);
//遍历的方式添加管道
for(i=0;i<processNum;i++)
{
//添加管道读端到epoll中,当子进程非忙碌时,写管道,
//父进程就知道子进程完成任务,可以再次分配任务了
epollInAdd(epfd,pData[i].pipeFd);//注册监听每一个子进程的管道对端
/*在epoll注册描述符的读事件
int epollInAdd(int epfd,int fd)
{
struct epoll_event event;
event.events=EPOLLIN;
event.data.fd=fd;
int ret;
ret=epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&event);
ERROR_CHECK(ret,-1,"epoll_ctl");
return 0;
}
*/
}
int readyFdCount,newFd,j;
char noBusyflag;
//开始等待socketFd和管道就绪
while(1)
{
readyFdCount=epoll_wait(epfd,evs,processNum+1,-1);
for(i=0;i<readyFdCount;i++)
{
if(evs[i].data.fd==socketFd)
{
newFd=accept(socketFd,NULL,NULL);//接收客户端请求
//遍历的方式找到非忙碌子进程
for(j=0;j<processNum;j++)
{
if(0==pData[j].busy)
{
sendFd(pData[j].pipeFd,newFd);//把任务发给对应的子进程
pData[j].busy=1;//子进程标识为忙碌
printf("%d pid is busy\n",pData[j].pid);
break;
}
}
//每次有客户端连接都会执行close。
//1.如果有空闲子进程,newFd发给子进程,newFd的引用计数为2,这里执行close之后
//newFd的引用计数变为1,这样,子进程发送完文件之后,再close(newFd),才会真正断开连接
//2.如果没有空闲的子进程,这里直接close,客户端会断开,不会发文件给客户端
//客户端可以过一会再来连接。
close(newFd);
}
//遍历所有的管道,如果有管道可读,说明子进程完成任务
//找到对应的子进程,读出管道的内容,并标记子进程为空闲状态
for(j=0;j<processNum;j++)
{
if(evs[i].data.fd==pData[j].pipeFd)
{
read(pData[j].pipeFd,&noBusyflag,1);//收到子进程的通知
pData[j].busy=0;//子进程设置为非忙碌
printf("%d pid is not busy\n",pData[j].pid);
break;
}
}
}
}
return 0;
}
epoll可以监听多个设备的就绪状态,让进程在事件发生之后再执行真正的读写操作
属于系统调用
1、epoll_create: 在内核创建一个 epoll 文件对象,包含监听事件结合和就绪设备集合
int epoll_create(int size);// 返回值:该文件对象对应的文件描述符
2、epoll_ctl:调整监听事件集合
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
// op: 操作参数: EPOLL_CTL_ADD: 添加 、EPOLL_CTL_MOD: 修改 、EPOLL_CTL_DEL: 删除
3、epoll_wait: 使线程陷入阻塞,直到监听的设备就绪或者超时
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);//最后一个-1表示永久等待
第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
第三部:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。
进程池中容易被问到的问题:
-
传输文件遇到的问题
-
TCP粘包问题
-
TCP是流式的,通过多次send发送的用户数据在缓冲区中是没有边界的,这样再接收数据的时候,由于不知道数据之间的分界线,就会把多个用户数据接到一起,比如文件名和文件内容接到一个buf里,造成数据混淆。
-
解决方法:
通过应用层的协议设计,人为的给每次发送的数据增加边界,这样接收方接收的时候,先接受数据的长度信息,再按照这个长度固定的去接这么长的数据,这样可以避免数据的混淆。
typedef struct{ int dataLen;//存储buf上要发送的数据上去 char buf[1000];//火车车厢 }train_t; // 发送文件流程:先发文件名,对端收到文件名之后,打开同名文件。 // 接着把文件内容读到buf中,再通过send发给对端,知道读到文件末尾 // 发完文件后,发送文件结束标志dataLen=0,表示文件发完,不会再发送了 int tranFile(int newFd) { signal(SIGPIPE,sigFunc); //小火车的方式发送,避免TCP粘包问题 train_t train; int ret; //1.发送文件名 train.dataLen=strlen(FILENAME); strcpy(train.buf,FILENAME); send(newFd,&train,4+train.dataLen,0); //2.发送文件大小给客户端,文件大小用于打印进度条 //同时客户端也可以根据文件大小判断是否接收完毕 struct stat buf; int fd=open(FILENAME,O_RDWR); fstat(fd,&buf);//获取文件大小 train.dataLen=sizeof(buf.st_size); memcpy(train.buf,&buf.st_size,train.dataLen);//注意要用memcpy send(newFd,&train,4+train.dataLen,0); //3.发送文件内容,循环读取,read返回值为从文件中读出的字节数,赋值给dataLen //dataLen是真正要发送的文件内容的长度,正好作为火车头 //当读到文件末尾时,read返回0,dataLen=0,就退出循环。 while((train.dataLen=read(fd,train.buf,sizeof(train.buf)))) { ret=send(newFd,&train,4+train.dataLen,0); if(-1==ret) { return -1; } } //4.发送文件结束标志 //退出循环后,train.dataLen=0,发4个字节的火车头过去, //对方收到火车头后,发现dataLen=0,知道文件发送完毕,退出循环接收。 send(newFd,&train,4,0); return 0; } //发送文件和接收文件如果字节数不一样会导致什么样的问题,这里为什么要用4 //水平触发及边沿触发如何切换 //select为什么最大限制为1024个 -
-
传输大文件(重要)
-
问题:传输文件时,两次send发送的两辆火车,可能是分批到达的,客户端接收时,第一次接收了第一辆小火车的全部数据,和第二辆小火车的部分数据,我们的程序在循环接收时,第一次while循环,接受了第一辆小火车的全部数据,第二次循环时,先接火车头,火车头里记录的长度是1000,接收文件内容时,1000个字节的文件内容,此时并没有全部达到接收缓冲区里,假设只到了400个字节,recv接收文件内容时,recv只负责把缓冲区中的数据拷贝到用户态的buf里,recv拷贝了400个字节的数据之后,直接返回,并没有把1000个字节的数据全部接到,写到文件里,再下一次接收火车车头时,没有接到真正的火车头,接收到的第二辆小火车的车厢里剩余600个字节的前4个字节,这4个字节的数据是文件内容,不是控制信息,这个火车头记录的数据是一个随机的值,不是我们需要的控制信息。
-
解决方式:
-
可以通过recv的第四个参数,设置为MSG_WAITALL,这样recv接收到了足够的数据之后才会返回(接收到了第三个参数指定长度的数据)。
-
循环接收,自己封装一下recv函数,编程recvCycle,每次接到了指定长度的数据,才会从函数返回。
int recvCycle(int sfd,void* buf,int recvLen) { char *p=(char*)buf; int total=0,ret; while(total<recvLen) { ret=recv(sfd,p+total,recvLen-total,0); total+=ret; } return 0; }
-
-
-
epoll V.S select
你在大学读书,你的朋友来找你:
//问优缺点:
select版宿管阿姨,就会带着你的朋友挨个房间找,直到找到你
epoll版阿姨,会先记下每位同学房间号, 你的朋友来时,只需告诉你的朋友你住在哪个房间即可,无需亲自带着你朋友满大楼找人
select的缺点:
1、每次调用select,都需把fd集合从用户态拷贝到内核态,fd很多时开销就很大
2、同时每次调用select都需在内核遍历传递进来的所有fd,fd很多时开销就很大
3、select支持的文件描述符数量太小了,默认最大支持1024个
//为什么是1024?
4、主动轮询效率很低
/*
对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
*/
epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
2、效率提升,不是轮询,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数 即Epoll最大的优点就在于它只关心“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll
3、内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll通过内核和用户空间共享一块内存来实现的
//问区别:
1、支持一个进程所能打开的最大连接数
select:
单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
epoll:
虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。
2、fd剧增后带来的IO效率问题
select:
因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
epoll:
因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
3、 消息传递方式
select:内核需要将消息传递到用户空间,都需要内核拷贝动作
epoll:epoll通过内核和用户空间共享一块内存来实现的。
//问epoll实现
epoll通过内核和用户空间共享一块内存来实现消息传递,epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的fd(文件描述符)。所以在使用完 epoll 后,必须调用 close() 关闭对应的文件描述符。epoll_ctl 绑定fd指向epoll实例,就是要监听的fd和event,将文件描述符 fd 添加到 epoll 实例的监听列表(红黑树),同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列(rdlist双链表)上。epoll_wait就相当于select,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪队列(rdlist双链表),因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表(rdlist双链表)是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。
总结(面试中可能会问到的问题)
- 进程的基本概念,常用命令,和程序的区别
- 如果你写了掌握进程池,多半会问对于匿名管道的理解、进程池是如何工作的、为什么要用epoll而不是select来管理进程,以及两个容易出现的情况(TCP沾包和传输大文件)
- epoll和select的区别(基本必问) 水平触发和边沿触发(可能会问)

浙公网安备 33010602011771号