网络编程-socket通信(二)

本文探讨了TCP协议中的分包和粘包问题,介绍了如何通过自定义协议和特定API(如recv()和send())来解决。内容涵盖了多进程socket示例,以及如何利用IO复用(select、poll、epoll)提高网络服务器性能,减少资源浪费。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

socket通信(二)

分包、粘包

TCP虽然可以保证顺序不变,但是依然可能会发生分包和粘包的问题:

  • 分包:发送一个包”helloworld“,但对方却收到了两个包”hello“和”world“

  • 粘包:和分包相反

recv()函数参数需要指定接收的字节长度,但是实际开发中是不知道对方发的消息内容有多少个字节,所以一般就是设置为buffer的长度,这就会导致出现粘包和分包的问题

解决方法:自定义一份协议规定 报文长度+报文内容:1010helloworld。报文长度采用ascii码(十进制)或者二进制的数字;接收时,可以先接收前四位看长度,然后再recv后面的报文内容。

ssize_t recv(int sockfd, void *buf, size_t len, int flags);len不对会导致粘包、分包

ssize_t send(int sockfd, const void *buf, size_t len, int flags); 写的时候也可能写不完(缓冲区不足),就需要循环多次使用

多进程socket

int main()
{
  // signal(SIGCHLD,SIG_IGN);  // 忽略子进程退出的信号,避免产生僵尸进程
 
  if (TcpServer.InitServer(5051)==false)
  { printf("服务端初始化失败,程序退出。\n"); return -1; }
 
  while (1)
  {
    if (TcpServer.Accept() == false) continue;
 
    if (fork()>0) { TcpServer.CloseClient(); continue; }  // 父进程回到while,继续Accept。
 
    // 子进程负责与客户端进行通信,直到客户端断开连接。
    TcpServer.CloseListen();
 
    printf("客户端已连接。\n");
 
    // 与客户端通信,接收客户端发过来的报文后,回复ok。
    char strbuffer[1024];
 
    while (1)
    {
      memset(strbuffer,0,sizeof(strbuffer));
      if (TcpServer.Recv(strbuffer,sizeof(strbuffer))<=0) break;
      printf("接收:%s\n",strbuffer);
 
      strcpy(strbuffer,"ok");
      if (TcpServer.Send(strbuffer,strlen(strbuffer))<=0) break;
      printf("发送:%s\n",strbuffer);
    }
 
    printf("客户端已断开连接。\n");
 
    return 0;  // 或者exit(0),子进程退出。
  }
}
  • fork返回值大于0,说明处于父进程,父进程就继续监听
  • fork返回值为0,说明处于子进程,子进程处理连接到socket

tips:这里用了一些freecplus的框架,封装了原始的socket相关函数,使用比较方便,大致的用途看函数名应该也能看出来

僵尸进程:子进程相比于父进程提前结束,而父进程也没有释放子进程的资源,就会导致僵尸进程,导致持续占用资源。使用signal(SIGCHLD,SIG_IGN)屏蔽子进程退出的信号,就可以避免僵尸进程。

上面的代码主要内容就是父进程持续监听,监听到了连接就开辟子进程处理。因为使用fork会导致子进程完全复制父进程的资源,这就导致了一些浪费:父进程不需要clientfd(因为监听到了之后都由子进程处理,父进程只负责监听),而子进程又不需要listenfd(因为监听是父进程的任务)。所以这就是以下代码中close的原因。

if (fork()>0) { TcpServer.CloseClient(); continue; }  // 父进程回到while,继续Accept。
// 子进程负责与客户端进行通信,直到客户端断开连接。
TcpServer.CloseListen();

IO复用

unix中,一切皆文件,socket也是文件,信息交换就是对这些流进行数据收发操作,即IO操作。

前面介绍的是多进程的网络服务器,这在高并发时并不适用。引进了IO复用:使用单个进程/线程就可以管理多个socket(上百万个)

IO复用的方案有三种,各有优缺点:

  1. select(早期采用)
  2. poll
  3. epoll

select

select流程如下图:

  • 监听的socket用于等待客户端的连接,客户端的socket用于等待客户端的报文。
  • 在多进程方法中,监听socket会阻塞在accept(),客户端socket会阻塞在recv();但是IO复用中,这两类socket都会阻塞在select()中
  • 如果socket集合中触发了事件(比如新的连接,数据传输或者连接断开),select就会返回有事件的socket。

整体代码如下:

while (1)
{
    // 调用select函数时,会改变socket集合的内容,所以要把socket集合保存下来,传一个临时的给select。
    fd_set tmpfdset = readfdset;

    int infds = select(maxfd+1,&tmpfdset,NULL,NULL,NULL);
    // printf("select infds=%d\n",infds);

    // 返回失败。
    if (infds < 0)
    {
        printf("select() failed.\n"); perror("select()"); break;
    }

    // 超时,在本程序中,select函数最后一个参数为空,不存在超时的情况,但以下代码还是留着。
    if (infds == 0)
    {
        printf("select() timeout.\n"); continue;
    }

    // 检查有事情发生的socket,包括监听和客户端连接的socket。
    // 这里是客户端的socket事件,每次都要遍历整个集合,因为可能有多个socket有事件。
    for (int eventfd=0; eventfd <= maxfd; eventfd++)
    {
        // 询问这个socket是否有事件,没有则continue
        if (FD_ISSET(eventfd,&tmpfdset)<=0) continue;

        // 发生事件,且是listensocket发生事件
        if (eventfd==listensock)
        { 
            // 如果发生事件的是listensock,表示有新的客户端连上来。
            // 创建新的client socket
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
            if (clientsock < 0)
            {
                printf("accept() failed.\n"); continue;
            }

            printf ("client(socket=%d) connected ok.\n",clientsock);

            // 把新的客户端socket加入集合。
            // 加入的是原集合,而不是tmpfdset
            FD_SET(clientsock,&readfdset);

            if (maxfd < clientsock) maxfd = clientsock;

            continue;
          }
          // 客户端socket发生事件
          else
          {
            // 客户端有数据过来或客户端的socket连接被断开。
            char buffer[1024];
            memset(buffer,0,sizeof(buffer));

            // 读取客户端的数据。
            ssize_t isize=read(eventfd,buffer,sizeof(buffer));

            // 发生了错误或socket被对方关闭。
            if (isize <=0)
            {
                printf("client(eventfd=%d) disconnected.\n",eventfd);

                close(eventfd);  // 关闭客户端的socket。

                FD_CLR(eventfd,&readfdset);  // 从集合中移去客户端的socket。

                // 重新计算maxfd的值,注意,只有当eventfd==maxfd时才需要计算。
                if (eventfd == maxfd)
                {
                    for (int ii=maxfd;ii>0;ii--)
                    {
                        if (FD_ISSET(ii,&readfdset))
                        {
                            maxfd = ii; break;
                        }
                    }

                    printf("maxfd=%d\n",maxfd);
                }

                continue;
              }

            printf("recv(eventfd=%d,size=%d):%s\n",eventfd,isize,buffer);

            // 把收到的报文发回给客户端。
            write(eventfd,buffer,strlen(buffer));
          }
    }
}

可以分为三个部分:

  • 创建socket集合副本,并通过select阻塞,有事件发生时再触发:

    // 调用select函数时,会改变socket集合的内容,所以要把socket集合保存下来,传一个临时的给select。
    fd_set tmpfdset = readfdset;
    
    int infds = select(maxfd+1,&tmpfdset,NULL,NULL,NULL);
    // printf("select infds=%d\n",infds);
    
    // 返回失败。
    if (infds < 0)
    {
        printf("select() failed.\n"); perror("select()"); break;
    }
    
    // 超时,在本程序中,select函数最后一个参数为空,不存在超时的情况,但以下代码还是留着。
    if (infds == 0)
    {
        printf("select() timeout.\n"); continue;
    }
    

    返回值infds分为正值或负值或0,负值和0在上面说明了,正值表示某些文件可读写(事件触发),所以之后接一个循环扫描FD_SET处理事件。

  • 处理连接事件

    // 询问这个socket是否有事件,没有则continue
    if (FD_ISSET(eventfd,&tmpfdset)<=0) continue;
    
    // 发生事件,且是listensocket发生事件
    if (eventfd==listensock)
    { 
        // 如果发生事件的是listensock,表示有新的客户端连上来。
        // 创建新的client socket
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
        if (clientsock < 0)
        {
            printf("accept() failed.\n"); continue;
        }
    
        printf ("client(socket=%d) connected ok.\n",clientsock);
    
        // 把新的客户端socket加入集合。
        // 加入的是原集合,而不是tmpfdset
        FD_SET(clientsock,&readfdset);
    
        if (maxfd < clientsock) maxfd = clientsock;
    
        continue;
    }
    
  • 处理数据写入事件和断开连接事件

    else
    {
        // 客户端有数据过来或客户端的socket连接被断开。
        char buffer[1024];
        memset(buffer,0,sizeof(buffer));
    
        // 读取客户端的数据。
        ssize_t isize=read(eventfd,buffer,sizeof(buffer));
    
        // 发生了错误或socket被对方关闭。
        if (isize <=0)
        {
            printf("client(eventfd=%d) disconnected.\n",eventfd);
    
            close(eventfd);  // 关闭客户端的socket。
    
            FD_CLR(eventfd,&readfdset);  // 从集合中移去客户端的socket。
    
            // 重新计算maxfd的值,注意,只有当eventfd==maxfd时才需要计算。
            if (eventfd == maxfd)
            {
                for (int ii=maxfd;ii>0;ii--)
                {
                    if (FD_ISSET(ii,&readfdset))
                    {
                        maxfd = ii; break;
                    }
                }
    
                printf("maxfd=%d\n",maxfd);
            }
    
            continue;
        }
    
        printf("recv(eventfd=%d,size=%d):%s\n",eventfd,isize,buffer);
    
        // 把收到的报文发回给客户端。
        write(eventfd,buffer,strlen(buffer));
    }
    

fdset默认设置大小为1024,相当于一个位图,哪里存放了fd,就将哪里置为1。所以select默认支持1024个socket的集合。可以调整,但一般不修改。

/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE		1024

一共有四个宏:

// 将fd位置置为0
void FD_CLR(int fd, fd_set *set);

// 判断fd是否在集合中,是则为1,否为0
int  FD_ISSET(int fd, fd_set *set);

// 将fd位置置为1
void FD_SET(int fd, fd_set *set);

// 将位图全部置为0
void FD_ZERO(fd_set *set);

select监视到位图中某个fd事件触发,就会把位图中其它fd都置为零,相当于清空其它未触发事件的fd,所以这是为什么可以用FD_ISSET检查事件触发的原因,也是为什么使用副本的原因。

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • nfds:fd集合个数,最大序号+1(maxfd+1)
  • readfds:读fd集合
  • writefds:写fd集合,因为写很少阻塞,一般不怎么用
  • exceptfds:很少用
  • timeout:超时机制,select阻塞等待的时间

返回值:成功的话,返回事件fd的个数(参数里面的三个fd集合都可能触发事件返回,只不过一般只用到了readfds)。

水平触发:如果数据一次没有被处理完,会立即触发事件再继续处理数据。

缺点

  1. 支持的fd集合太小了,虽然可以调整,但集合越大效率越低,详见后面两点
  2. 每次调用select,都需要把fdset从用户态拷贝到内核态
  3. 每次想要找到事件触发的fd,都要遍历整个fd集合

poll

和select没有本质上的区别,多个fd也是采用轮询管理,主要区别如下:

  • poll没有最大fd数量的限制。
  • poll采用数组管理fdset

poll函数:int poll(struct pollfd *fds, nfds_t nfds, int timeout);

pollfd结构体:

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

初始化fdset,maxfd是自己设置的最大fd,MAXFDS是整个fds数组的容量,fd为负数则会被忽略。

int maxfd;   // fds数组中需要监视的socket的大小。
struct pollfd fds[MAXNFDS];  // fds存放需要监视的socket。

for (int ii=0;ii<MAXNFDS;ii++) fds[ii].fd=-1; // 初始化数组,把全部的fd设置为-1。

初始化监听socket。

// 把listensock添加到数组中。
fds[listensock].fd=listensock;
fds[listensock].events=POLLIN;  // 有数据可读事件,包括新客户端的连接、客户端socket有数据可读和客户端socket断开三种情况。
maxfd=listensock;

events和revents可以被设置为POLLIN、POLLOUT等等

使用方法和select一样,不过不需要事先备份,因为poll返回修改的是revent:

int infds = poll(fds,maxfd+1,5000);
for (int eventfd=0; eventfd <= maxfd; eventfd++)
{
    if (fds[eventfd].fd<0) continue;

    if ((fds[eventfd].revents&POLLIN)==0) continue;

    fds[eventfd].revents=0;  // 先把revents清空。

    if (eventfd==listensock)
    {
        // 如果发生事件的是listensock,表示有新的客户端连上来。
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
        if (clientsock < 0)
        {
            printf("accept() failed.\n"); continue;
        }

        printf ("client(socket=%d) connected ok.\n",clientsock);

        if (clientsock>MAXNFDS)
        {    
            printf("clientsock(%d)>MAXNFDS(%d)\n",clientsock,MAXNFDS); close(clientsock); continue;
        }

        fds[clientsock].fd=clientsock;
        fds[clientsock].events=POLLIN; 
        fds[clientsock].revents=0; 
        if (maxfd < clientsock) maxfd = clientsock;

        printf("maxfd=%d\n",maxfd);
        continue;
    }
    else 
    {
        // 客户端有数据过来或客户端的socket连接被断开。
        char buffer[1024];
        memset(buffer,0,sizeof(buffer));

        // 读取客户端的数据。
        ssize_t isize=read(eventfd,buffer,sizeof(buffer));

        // 发生了错误或socket被对方关闭。
        if (isize <=0)
        {
            printf("client(eventfd=%d) disconnected.\n",eventfd);

            close(eventfd);  // 关闭客户端的socket。

            fds[eventfd].fd=-1;

            // 重新计算maxfd的值,注意,只有当eventfd==maxfd时才需要计算。
            if (eventfd == maxfd)
            {
                for (int ii=maxfd;ii>0;ii--)
                {
                    if ( fds[ii].fd != -1)
                    {
                        maxfd = ii; break;
                    }
                }

                printf("maxfd=%d\n",maxfd);
            }

            continue;
        }

        printf("recv(eventfd=%d,size=%d):%s\n",eventfd,isize,buffer);

        // 把收到的报文发回给客户端。
        write(eventfd,buffer,strlen(buffer));
    }
}

epoll

解决了前面两者的所有问题:fdset用户态和内核态间的拷贝以及轮询开销,采用了最合理的解决方案。

  1. 创建epoll的引用,本身就是一个文件描述符

    epollfd = epoll_create(int size);

    linux2.6.8之后这个值就被忽略了,但是必须要给一个大于0的值。

  2. 注册需要监视的fd和事件

    // 添加监听描述符事件
    struct epoll_event ev;
    ev.data.fd = listensock;
    ev.events = EPOLLIN;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,listensock,&ev);
    
    /*
     EPOLL_CTL_ADD
                  Add fd to the interest list and associate the settings specified in event with the internal file linked to fd.
    
    EPOLL_CTL_MOD
                  Change the settings associated with fd in the interest list to the new settings specified in event.
    
    EPOLL_CTL_DEL
                  Remove (deregister) the target file descriptor fd from the interest list.  The event argument is ignored and can  be  NULL  (but
                  see BUGS below).
    
    */
    

    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    需要监视某个socket,就只需要通过epoll_ctl添加进去即可(设置第二个参数为EPOLL_CTL_ADD),同理移除使用EPOLL_CTL_DEL,如果修改监视的event则使用EPOLL_CTL_MOD

  3. 等待事件发生

    struct epoll_event events[MAXEVENTS]; // 存放有事件发生的结构数组。
    
    // 等待监视的socket有事件发生。
    int infds = epoll_wait(epollfd,events,MAXEVENTS,-1);
    

    如果有事件发生了,就会把事件写入events,MAXEVENTS表示一次返回多少个事件(也就是infds的值);最后一个参数设置超时时间。

epoll_event结构体:

struct epoll_event
{
  uint32_t events;	/* Epoll events */
  epoll_data_t data;	/* User data variable */
}

typedef union epoll_data
{
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

epoll和前面二者主要区别代码如下,epoll可以只遍历有事件发生的数组:

// 遍历有事件发生的结构数组。
for (int ii=0;ii<infds;ii++)
{
    if ((events[ii].data.fd == listensock) &&(events[ii].events & EPOLLIN))
    {
        // 如果发生事件的是listensock,表示有新的客户端连上来。
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
        if (clientsock < 0)
        {
            printf("accept() failed.\n"); continue;
        }

        // 把新的客户端添加到epoll中。
        memset(&ev,0,sizeof(struct epoll_event));
        ev.data.fd = clientsock;
        ev.events = EPOLLIN;
        epoll_ctl(epollfd,EPOLL_CTL_ADD,clientsock,&ev);

        printf ("client(socket=%d) connected ok.\n",clientsock);

        continue;
    }
    else if (events[ii].events & EPOLLIN)
    {
        // 客户端有数据过来或客户端的socket连接被断开。
        char buffer[1024];
        memset(buffer,0,sizeof(buffer));

        // 读取客户端的数据。
        ssize_t isize=read(events[ii].data.fd,buffer,sizeof(buffer));

        // 发生了错误或socket被对方关闭。
        if (isize <=0)
        {
            printf("client(eventfd=%d) disconnected.\n",events[ii].data.fd);

            // 把已断开的客户端从epoll中删除。
            memset(&ev,0,sizeof(struct epoll_event));
            ev.events = EPOLLIN;
            ev.data.fd = events[ii].data.fd;
            epoll_ctl(epollfd,EPOLL_CTL_DEL,events[ii].data.fd,&ev);
            close(events[ii].data.fd);
            continue;
        }

        printf("recv(eventfd=%d,size=%d):%s\n",events[ii].data.fd,isize,buffer);

        // 把收到的报文发回给客户端。
        write(events[ii].data.fd,buffer,strlen(buffer));
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值