socket编程模型

socket网络编程

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

  • 一个连接对应两个进程,分别对应客户端和服务器端的socket.

背景知识

大端、小端与网络字节序

内存背景知识:

大端(Big-Endian)

数据的高位更加靠近低地址。以数据0x12345678在内存中的存储为例:

内存地址0x000010000x000010010x000010020x00001003
存放内容0x120x340x560x78
小端(Little-Endian)

数据的低位更加靠近低地址。以数据0x12345678在内存中的存储为例:

内存地址0x000010000x000010010x000010020x00001003
存放内容0x780x560x340x12
ip地址

以6.7.8.9为例, 其对应的32位无符号整形为0x06070809,正常在内存中存储的顺序(这里以小端存储为例子)应为 0x09 0x08 0x07 0x06, 但是经过inet_pton将点分十进制Ip地址转为无符号整形时采用了网络字节序,所以0x06070809 在内存中的存储顺序变为 0x06 0x07 0x08 0x09, 此时通过printf打印此值,会打成0x09080706

详见

tcp 网络编程模型

基本概念:

  • 网络io: 等价于客户端与服务器建立连接的socket, 可以通过此socket进行读写io操作,从而传递信息
  • 多路复用网络io: 通过多线程等方式实现多个客户端同时访问服务器

服务器端

基本模型如下:

socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听

while(1)//循环处理客户端的连接请求
{
   c_fd  = accept(...);//三次握手建立连接

   while(1)
   {
      int nr = read(...);//读取客户端消息
      if(nr==0)
      {
         break;
      }
      process(...);//业务处理
      write(...);//发送处理后的数据
   }
   close(c_fd);//关闭连接
}
创建socket

类似于open()打开文件,返回文件描述符, 创建socket网络通讯端口, 返回socket的文件描述符,可以像文件一下read/write在网络上进行收发数据.

int socket (int domain, int type, int protocol);

domain:

  • AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
  • AF_INET6 与上面类似,不过是来用IPv6的地址。
  • AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用。

type:

  • SOCK_STREAM , 用于TCP可靠传输
  • SOCK_DGRAM, 用于UDP不可靠传输

protocol:

  • 0表示默认协议?
绑定ip+端口
  • 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们.
  • 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们.

将socket与addr ip地址进行绑定, 调用函数:

int bind (int sockfd, const struct sockaddr *addr, socklen_t addrlen);

addr:
存放ip地址+port端口号的结构体

addrlen:
=sizeof(addr), 不是sizeof(struct sockaddr)

监听listen

socket被创建出来的时候都默认是一个主动socket,也就说,内核会认为这个socket之后某个时候会调用connect()主动向别的设备发起连接。这个默认对客户端socket来说很合理,但是监听socket可不行,它只能等着客户端连接自己,因此我们需要调用listen()将监听socket从主动设置为被动,明确告诉内核:你要接受指向这个监听socket的连接请求!

监听socket绑定的ip端口号,是否有连接请求

int listen (int sockfd, int backlog);

backlog:

相当于客户端可以同时连接服务器的个数, 如果超过了怎么办,进入未决队列?

循环处理客户端的连接请求
  1. 三次握手建立连接

服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞直到接收到客户的连接请求等待客户端连接的到来。

客户端对server的连接请求会被放入未决连接队列, 服务端通过accept()函数提取队列中的第一个连接请求, 创建并返回一个已连接 Socket, 原始的监听 Socket并不受影响, 未被处理的连接将在队列中排队

int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen);

addr:
传出参数,返回客户端的地址信息,含IP地址和端口号

返回值:
成功返回一个新的socket文件描述符,用于和客户端通信

  1. 读取客户端消息
read()
  1. 业务处理
process()
  1. 发送处理后的数据
write()

将读取客户端消息,业务处理,发送处理后的数据进行封装:


int recv_send(int c_fd)
{
   int nr = read(...);//读取客户端消息
   if(nr==-1)
   {
      exit();
   }
   else if(nr == 0)
   {
      return nr;
   }
   process(...);//业务处理
   write(...);//发送处理后的数据
}

tcp服务器网络模型简化为:

socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听

while(1)//循环处理客户端的连接请求
{
   c_fd  = accept(...);//三次握手建立连接
   while(1)
   {
      int nr = recv_send(...);//读取,处理,并发送消息
      if(nr==0)// 客户端断开连接
      {
         break;
      }
   }
   close(c_fd);//关闭连接
}
关闭连接

close()

客户端

创建socket
向服务器端发送连接请求

客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号, 然后进行TCP三次握手

处理业务逻辑
write
read
关闭连接

参考文章

udp 网络编程模型

与tcp网络模型的区别:

  1. 创建套接字时的type(参数2)不同。
    • TCP通信,使用SOCK_STREAM
    • UDP通信,使用SOCK_DGRAM
  2. 发送数据和接收数据时,使用的接口不同
    • TCP通信,发送数据,使用write(或send), 接收数据,使用read(或recv)
    • UDP特性,发送数据,使用sendto,接收数据,服务器端使用recvfrom, 客户端使用recv
  3. 不使用listen
  4. 不需要先建立连接(TCP客户端和服务器端分别使用connect和receive建立连接)

tcp 多线程网络编程模型

在这里插入图片描述

伪代码:

pthread_fun(...)
{
   while(1)
   {
      int nr = recv_send(...);//读取,处理并发送业务消息
      if(nr==0)
      {
         break;
      }
   }
   close(c_fd);//关闭连接
}

socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听

while(1)//循环处理客户端的连接请求
{
   c_fd  = accept(...);//三次握手建立连接
   pthread_create(..., pthread_fun, (void *)&c_fd); // 创建多线程
}

tcp 多进程网络编程模型

在这里插入图片描述

伪代码:

socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听

while(1)//循环处理客户端的连接请求
{
   c_fd  = accept(...);//三次握手建立连接

   if(fork(...)==0)
   {
      while(1)
      {
         int nr = recv_send(...);//读取,处理并发送业务消息
         if(nr==0)
         {
            break;
         }
      }
      close(c_fd);
   }
   close(c_fd);//关闭连接
}

io多路复用

在这里插入图片描述

为什么要使用io多路复用?

以下面这句代码为例:

int iResult = recv(s, buffer,1024);

这是用来接收数据的,在默认的阻塞模式下的套接字里,recv会阻塞在那里,直到套接字连接上有数据可读,把数据读到buffer里后recv函数才会返回,不然就会一直阻塞在那里.

在单线程的程序里出现这种情况会导致主线程(单线程程序里只有一个默认的主线程)被阻塞,这样整个程序被锁死在这里,如果永 远没数据发送过来,那么程序就会被永远锁死.

这个问题可以用多线程解决,但是在有多个套接字连接的情况下,这不是一个好的选择,扩展性很差.

背景

  • 多进程模型开销大, 复制所有共享数据, 包括用户空间资源(虚拟内存,栈,全局变量)和系统内核空间(内存堆栈,寄存器等), 可并发~100个客户端

  • 多线程模型中, 文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多. 但是如果频繁创建和销毁线程,系统开销也是不小的. 线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理.

  • IO多路复用技术可实现单线程并发, 服务器端采用单线程通过select/poll/epoll等系统调用获取fd列表, 遍历有事件的fd进行accept/recv/send,单线程默认可处理1024客户端的并发

select多路复用

工作原理:

传入要监听的文件描述符集合(可读、可写或异常)开始监听,select处于阻塞状态,当有事件发生或设置的等待时间timeout到了就会返回,返回之前自动去除集合中无事件发生的文件描述符,返回时传出有事件发生的文件描述符集合。但select传出的集合并没有告诉用户集合中包括哪几个就绪的文件描述符,需要用户后续进行遍历操作。

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

nfds:

nfds是最大文件描述符个数,用于遍历

readfds:

传入传出参数,若不为null,select调用时传入要监听的可读文件描述符集合readfds,select返回时传出发生可读事件的文件描述符集合readfds(发生可读事件的socket置1,否则置0),
select返回一个大于0的值,表示有文件可读;如果没有可读的文件,则根据timeout参数的值再判断是否超时,若超出timeout的时间,select返回0;若发生错误返回负值

writefds:

传入传出参数,若不为null,select调用时传入要监听的可写文件描述符集合,select返回时传出发生可写事件的文件描述符集合
select返回一个大于0的值,表示有文件可写;如果没有可写的文件,则根据timeout参数的值再判断是否超时,若超出timeout的时间,select返回0;若发生错误返回负值

exceptfds:

传出参数,若不为null,select返回时传出发生事件(包括可读和可写)中异常事件的文件描述符集合
select返回一个大于0的值,表示有异常发生在文件集合中;如果没有异常发生,则根据timeout参数的值再判断是否超时,若超出timeout的时间,select返回0;若发生错误返回负值

timeout:

若设置为NULL,则select一直阻塞直到有事件发生;
若设置为0,则select为非阻塞模式,执行后立即返回;
若设置为一个大于0的数,即select的阻塞时间,若阻塞时间内有事件发生就返回,否则时间到了立即返回

fd_set 是自定义数据结构,位域的使用方式, 一个bit位代表一个文件描述符, 以8位为例, 00001000代表fd=4的文件描述符, 同理10001000代表fd=4和8的文件描述符被置位.

select多路复用模型伪代码如下:

socket(...);
bind(...);
listen(...);

while(1)
{
   select(...);
   for(int fd; fd<=maxfd; fd++)
   {
      if(fd == s_fd)
      {
         c_fd  = accept(...);
         FD_SET(c_fd, &readfds);
         maxfd = maxfd > c_fd ? maxfd : c_fd;
      }
      else
      {
         int nr = recv_send(...);
         if(nr==0)
         {
            close(fd);
            FD_CLR(fd, &readfds);
         }
      }
   }
}

poll多路复用

int poll(struct pollfd *fds, unsigned long nfds, int timeout); 

返回值:若有就绪描述符则为其数目,若超时则为0,若出错则为-1.

fds:
struct pollfd类型的数组, 结构体中存储了三个数据:

  • 第一个是要监听的文件描述符fd, fd==-1时,不检测此fd
  • 第二个是描述符上待检测的时间类型events,如可读POLLIN,可写POLLOUT
  • 第三个是检测结果存储, returned events

poll 不会改变fds的值,不需要备份

nfds:
数组fds的大小, 相当于向poll申请的事件检测的个数

timeout:

  • timeout <0 时, 一直等待, 知道有事件发生
  • timeout =0, 直接返回, 很少用
  • timeout > 0, 等待的毫秒数

与select相比,可显示控制监控事件的个数(数组fds的大小).

socket(...);
bind(...);
listen(...);

poll_fdset[MAX_SOCKET];

//设置监听socket
poll_fdset[0].fd = listen_fd;
poll_fdset[0].events = POLLIN;

while(1)
{
   int numpoll = poll(...);//返回待处理的socket的个数

   //处理监听socket
   if(poll_fdset[0].revents & POLLIN)
   {
      int c_fd = accept(...);
      //把c_fd加到检测数组中
       for (int i = 1; i < NUM_SOCKETS; i++)
      {
        if (poll_fdset[i].fd < 0)
        {
          poll_fdset[i].fd = c_fd;
          poll_fdset[i].events = POLLIN;
          break;
        }
      }
   }

//处理其他连接socket的信息
   for(int i=1; i<MAX_SOCKET; i++)
   {
      if ((c_fd = poll_fdset[i].fd) < 0)
        continue;
      if (poll_fdset[i].revents & POLLIN)
      {
        int nr = recv_send(c_fd);
        if (nr == 0) /*客户端主动断开连接*/
        {
          close(c_fd);
          poll_fdset[i].fd = -1;
        }
        if (--numpoll <= 0) /*说明待处理socket已处理完*/
          break;
      }
   }
}

epoll多路复用

要想使用 epoll 模型,必须先需要创建一个 epollfd,这需要使用 epoll_create 函数去创建:

int epoll_create(int size);

int epollfd = epoll_create(1);

有了 epollfd 之后,我们需要将我们需要检测事件的其他 fd 绑定到这个 epollfd 上,或者修改一个已经绑定上去的 fd 的事件类型,或者在不需要时将 fd 从 epollfd 上解绑,这都可以使用 epoll_ctl 函数:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
  • epfd: 通过epoll_create创建的epollfd
  • op,操作类型,取值有 EPOLL_CTL_ADDEPOLL_CTL_MODEPOLL_CTL_DEL,分别表示向 epollfd 上添加、修改和移除一个其他 fd,当取值是 EPOLL_CTL_DEL,第四个参数 event 忽略不计,可以设置为 NULL
  • fd,即需要被操作的 fd
  • event,这是一个 epoll_event 结构体的地址,epoll_event 结构体定义如下:
struct epoll_event
{
  uint32_t     events;      /* 需要检测的 fd 事件,取值与 poll 函数一样 */
  epoll_data_t data;        /* 用户自定义数据 */
};
  • 函数返回值:epoll_ctl 调用成功返回 0,调用失败返回 -1,你可以通过 errno 错误码获取具体的错误原因

创建了 epollfd,设置好某个 fd 上需要检测事件并将该 fd 绑定到 epollfd 上去后,我们就可以调用 epoll_wait 检测事件了,epoll_wait 函数签名如下:

int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);//返回事件发生的个数
  • epfd:先前创建的epollfd
  • events:仅出参, 输出事件就绪的events集合,可直接操作,无需遍历; poll 函数的事件集合调用前后数量都未改变,需要遍历事件的revents字段检查是否有事件发生
  • maxevents: events数组的大小
  • timeout:超时时间,单位ms
  • 返回事件就绪的个数

基本使用模型如下:

while (true)
{
    epoll_event epoll_events[1024];
    int n = epoll_wait(epollfd, epoll_events, 1024, 1000);
    if (n < 0)
    {
        //被信号中断
        if (errno == EINTR)
            continue;

        //出错,退出
        break;
    }
    else if (n == 0)
    {
        //超时,继续
        continue;
    }

    for (size_t i = 0; i < n; ++i)
    {
        // 处理可读事件
        if (epoll_events[i].events & POLLIN)
        {
         if(epoll_events[i].data.fd == listenfd)
         {
            accept(...);
         }
         else
         {
            read(...);
            write(...);
         }
        }
        // 处理可写事件
        else if (epoll_events[i].events & POLLOUT)
        {
        }
        //处理出错事件
        else if (epoll_events[i].events & POLLERR)
        {
        }
    }
}

参考文章:

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值