由进程池衍生出来的知识点总结

2022-4-20分享

进程池->管道部分内容->epoll和select->socket套接字编程部分内容->tcp部分内容

进程池:

     定义一个池子,在里面放上固定数量的进程,有需求来了,就拿一个池中的进程来处理任务,等到处理完毕,进程并不关闭,而是将进程再放回进程池中继续等待任务。如果有很多任务需要执行,池中的进程数量不够,任务就要等待之前的进程执行任务完毕归来,拿到空闲进程才能继续执行。也就是说,池中进程的数量是固定的,那么同一时间最多有固定数量的进程在运行。这样不会增加操作系统的调度难度,还节省了开闭进程的时间,也一定程度上能够实现并发效果。

image-20220420143612702

进程相关部分内容:

进程是什么?

进程是操作系统资源分配的基本单位,用PCB描述、记录进程的信息。

  • 进程相关的命令:
  1. ps -elf 查看系统中的进程。ps 命令是一个采样的信息。

  2. ps -aux 可以查看进程的CPU和内存的占用率。

  3. echo $$打印当前bash的进程ID。

  4. top命令 动态的显示进程的信息,展示的是系统中CPU占用率最高的20个进程。

  5. kill命令,给进程发信号,使用方式:kill -信号的编号 进程ID。通过kill -l可以查看所有信号。

  6. nice命令按照指定的优先级运行进程,renice命令可以修改进程的nice值,nice值的范围:-20-19。

    1. nice -n 可执行程序
    2. renice -n 指定的nice值 -p 进程ID
  7. 通过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监控中已经发生的事件。

进程池中容易被问到的问题:

  1. 传输文件遇到的问题

    1. TCP粘包问题

      1. TCP是流式的,通过多次send发送的用户数据在缓冲区中是没有边界的,这样再接收数据的时候,由于不知道数据之间的分界线,就会把多个用户数据接到一起,比如文件名和文件内容接到一个buf里,造成数据混淆。

      2. 解决方法:

        通过应用层的协议设计,人为的给每次发送的数据增加边界,这样接收方接收的时候,先接受数据的长度信息,再按照这个长度固定的去接这么长的数据,这样可以避免数据的混淆。

      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个
      
      
      
    2. 传输大文件(重要)

      1. 问题:传输文件时,两次send发送的两辆火车,可能是分批到达的,客户端接收时,第一次接收了第一辆小火车的全部数据,和第二辆小火车的部分数据,我们的程序在循环接收时,第一次while循环,接受了第一辆小火车的全部数据,第二次循环时,先接火车头,火车头里记录的长度是1000,接收文件内容时,1000个字节的文件内容,此时并没有全部达到接收缓冲区里,假设只到了400个字节,recv接收文件内容时,recv只负责把缓冲区中的数据拷贝到用户态的buf里,recv拷贝了400个字节的数据之后,直接返回,并没有把1000个字节的数据全部接到,写到文件里,再下一次接收火车车头时,没有接到真正的火车头,接收到的第二辆小火车的车厢里剩余600个字节的前4个字节,这4个字节的数据是文件内容,不是控制信息,这个火车头记录的数据是一个随机的值,不是我们需要的控制信息。

      2. 解决方式:

        1. 可以通过recv的第四个参数,设置为MSG_WAITALL,这样recv接收到了足够的数据之后才会返回(接收到了第三个参数指定长度的数据)。

        2. 循环接收,自己封装一下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通过内核和用户空间共享一块内存来实现的

image-20220420154557145

//问区别:
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的区别(基本必问) 水平触发和边沿触发(可能会问)
posted @ 2022-04-20 19:39  Fancele  阅读(91)  评论(0)    收藏  举报