暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

IO多路复用原理

lex技术 2021-12-25
873

本篇文章主要讲解IO多路复用原理,主要分为2部分:Socket通信原理;Select/Poll/Epoll原理。


一、Socket通信原理

1)socket 基本原理

首次来看Socket所在的位置,如下图所示:


Socket是内核提供给应用层的一层抽象,能够通过使用简单的api来进行数据读写。在Linux系统中,一切皆文件。Socket也是一类文件,我们可以像使用普通文件一样,对其进行open、read、write、close等操作。程序是通过fd来访问对应的Socket。

我们通过一个实例来体会一下

1.进入当前shell进程所在的fd列表,cd /proc/$$/fd

可以看到有一些fd为0(stdin),1(stdout)和2(stderr)。 

2.通过命令exec 4<> /dev/tcp/www.tencent.com/80来创建一个socket fd


通过lsof可以看到对应的socket连接了。接下来就可以对这个socket进行读写了。

3.socket实例

接下来,我们来看看一般编写socket的代码实例

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(){
//创建套接字
int listenfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
//将套接字和IP、端口绑定
struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(1234);
bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
//进入监听状态,等待用户发起请求
listen(listenfd, 20);
//接收客户端请求
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size = sizeof(clnt_addr);
int connfd = accept(listenfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
//向客户端发送数据
char str[] = "Hello World!";
write(connfd, str, sizeof(str));


//关闭套接字
close(connfd);
close(listenfd);
return 0;
}

实例代码主要包括bind、listen、accept、write/read、close过程。

bind函数:是将socket绑定到指定的ip和端口

listen函数:是监听客户端的连接,并指定连接队列来接收请求

accept函数:listen监听队列中接受一个连接,并且获取connfd

如下图所示,整个过程服务端产生两个文件fd:一个用于服务端监听的fd和一个用户客户端连接的fd。



2)socket 存储结构

每个socket数据结构都有一个sock数据结构成员,sock是对socket的扩充,两者一一对应,socket->sk指向对应的sock,sock->socket 指向对应的socket

struct socket {  
    socket_state            state; //socket所处的状态
unsigned long flags;
const struct proto_ops *ops;
struct fasync_struct *fasync_list;
struct file *file;
    struct sock             *sk; //sock指针
    wait_queue_head_t       wait;//sock的等待队列,在TCP需要等待时就sleep在这个队列上
short type;
};

其中sock结构体如下:


struct sock {
__u32 daddr; // 外部ip地址
__u32 rcv_saddr; // 记录套接字所绑定的本地ip地址
  __u16 dport;        // 目的端口
  __u16 sport;        // 源端口
  
  unsigned short family;         // 协议族,例如PF_INET
/* sock的收发都是要占用内存的,即发送缓冲区和接收缓冲区。系统对这些内存的使用是有限制的。通常,每个sock都会从配额里
预先分配一些,这就是forward_alloc, 具体分配时:
1)比如收到一个skb,则要计算到rmem_alloc中,并从forward_alloc中扣除。接收处理完成后(如用户态读取),则释放skb,并利
用tcp_rfree()把该skb的内存反还给forward_alloc。
2)发送一个skb,也要暂时放到发送缓冲区,这也要计算到wmem_queued中,并从forward_alloc中扣除。真正发送完成后,也释放
skb,并反还forward_alloc。当从forward_alloc中扣除的时候,有可能forward_alloc不够,此时就要调用tcp_mem_schedule()来增
加forward_alloc,当然,不是随便想加就可以加的,系统对整个TCP的内存使用有总的限制,即sysctl_tcp_mem[3]。也对每个sock
的内存使用分别有限制,即sysctl_tcp_rmem[3]和sysctl_tcp_wmem[3]。只有满足这些限制(有一定的灵活性),forward_alloc才
能增加。当发现内存紧张的时候,还会调用tcp_mem_reclaim()来回收forward_alloc预先分配的配额。
*/
int rcvbuf; // 接受缓冲区的大小(按字节)
int sndbuf; // 发送缓冲区的大小(按字节)


struct sk_buff_head receive_queue; // 接受队列
  struct sk_buff_head write_queue;   // 发送队列
......
};

由以上可知,一个socket对应主要包括了四元组、接收队列、发送队列、等待队列(进程)



以read读数据为例,当socket调用read时,背后是如何运行的:

1.当一个进程在调用read时,如果对应的socket缓冲区没有数据,则会阻塞当前进程,操作系统将该队列从运行队列中移除,并将该进程添加到这个这个socket对应的等待队列中。


2.socket数据已经准备好时,操作系统将该socket下的等待队列移除,将进程添加到工作队列中,使得进程再次执行并获取socket缓冲区的数据。


3)网络数据包收发过程

1、数据包接收过程

    一个数据包进入服务器的网卡后,数据是如何一步一步到达用户应用进程的?这是笔者学习socket通信时的疑问。

    1.当数据包按照FIFO顺序存入网卡的接受队列。

    2.网卡通过DMA机制将数据拷贝到内核缓冲区。

    3.拷贝完成后向系统发送中断请求,操作系统会将数据包转换成skb格式,交由tcp/ip协议栈处理。

    4.协议栈一层一层进行拆包头处理,此处sk_buffer设计比较巧妙,为了性能,只是指针的移动,不涉及数据包的在一层一层复制。

    5.在传输层会去出源 IP、源端口、目的 IP、目的端口,获取到对应的socket,从而将数据拷贝到socket缓冲区。    

    6.操作系统唤醒进程,用户进程就可以通过read从socket缓冲区读取数据。


个人理解:由于各层之间的调用不是同步的,且为了各层解耦,因此各层之间都设置了缓存。


二、IO多路复用原理

1)阻塞IO

讲解IO多路复用时,我们先来看看阻塞IO模型是如何工作的?

如上图所示,当应用程序调用recvfrom系统调用时,该操作将导致进程阻塞。等待内核通过中断等一系列操作将网络数据拷贝到内核态(Socket缓冲区),再将内核态数据拷贝到用户态的内存这种方式只能通过一个线程来管理一个连接,一般需要采用多线程方式来处理请求。需要大量线程来维护网络连接,并且线程频繁进行上下文切换,导致性能低下。

2)非阻塞IO

非阻塞IO,用户进程调用recvfrom时,立即返回结果。不会阻塞。优点是:不像阻塞IO,调用接口会阻塞,可以使用一个线程来管理多个网络连接。缺点是:进程需要不断轮训调用内核接口,导致大量的系统调用和cpu资源。有没有更好的方式呢? IO多路复用出现了。

3)IO多路复用

IO多路复用,中的"多路"二字。表示一次可以监听多个Socket事件,如果这批Socket连接中有事件就绪,则内核会立即返回就绪的fd列表。进程可以再次发起recvfrom系统调用,从内核Socket缓冲区拷贝到用户态。

一次调用可以监听多个Socket连接,而阻塞IO、非阻塞IO只能一个一个进行监听。性能有了明显提升;调用select,并且向内核传递需要监控的网络连接,一旦有某个Socket连接有事件了,内核会返回告知。不必像非阻塞IO那样不停地进行轮训。

IO多路复用机制一般有3种:select/poll/epll。

4)select/poll

先man一下select,可以看到select的,需要传递一批需要监控读fd列表、写事件fd列表、异常fd列表。返回值为int,表示就绪列表的个数。



下图为select的一个代码实例,由于select返回的是一个int类型的就绪fd的个数,还需要再次遍历监听的fd列表,找到就绪的fd进行读写。



select有如下的弊端:

  • 每次调用select都需将线程加入socket的等待队列,每次唤醒都需从每个队列中移除。

  • 由于只有一个字段记录关注和发生事件,每次调用之前要重新初始化fd_set结构体。

  • 线程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次

所以一般设置的监听的最大个数为1024。

4)epoll

1.epoll功能方便进行优化即在内核中增加一些数据结构来存储需要监听的fd,避免每次监听都需要重新添加。该数据结构为红黑树。

2.select返回的是一个int值,无法告诉用户进程哪些socket已经就绪了。epoll在内核中增加一些数据结构,直接告诉用户进程哪些进程已经就绪了。该数据结构为就绪列表。



epoll在内核中增加红黑树数据结构,存储了需要监听的socket,因此避免了像select一样频繁的拷贝开销。并且在内核中存储就绪列表,直接告诉用户进程就绪的socket,避免了像select返回后还需遍历fd列表获取就绪的socket。


参考文档:

http://abcdxyzk.github.io/blog/2015/06/12/kernel-net-sock-socket/

http://c.biancheng.net/cpp/html/3030.html

https://www.zbpblog.com/blog-211.html

https://www.cnblogs.com/hilyhoo/articles/1547105.html

https://blog.csdn.net/yangguosb/article/details/103562983

https://cloud.tencent.com/developer/article/1793196

https://www.masterraghu.com/subjects/np/introduction/unix_network_programming_v1.3/ch06lev1sec2.html

文章转载自lex技术,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论