Linux网络连接原理

您所在的位置:网站首页 pops指令 Linux网络连接原理

Linux网络连接原理

2023-03-01 22:34| 来源: 网络整理| 查看: 265

一、作用

文件系统包含磁盘、文件格式以及与内核的交互。

格式化磁盘,分为超级块、inode区、数据区。定义文件的头部,包含文件的基本信息、访问权限以及索引,定位到磁盘上盘块。需要能够管理磁盘空间的分配与回收。内核从文件读取一页后,需要管理文件偏移量到页缓存的映射关系。操作系统分层

inode维护了address_space模块,从而获得自身文件在内存中的缓存信息。 address_space内部维护了一个树结构指向文件读入内存所有的内存页。

struct address_space { struct inode *host; /* Owner, either the inode or the block_device */ struct radix_tree_root page_tree; /* Cached pages */ spinlock_t tree_lock; /* page_tree lock */ struct prio_tree_root i_mmap; /* Tree of private and shared mappings */ struct spinlock_t i_mmap_lock; /* Protects @i_mmap */ unsigned long nrpages; /* total number of pages */ struct address_space_operations *a_ops; /* operations table */ ... }二、进程和虚拟文件系统的交互

内核使用task_struct来表示单个进程的描述符,维护进程的所有信息,其中包括files指针来指向结构体files_struct,files_struct中维护了文件描述符。

1 读进程调用库函数向内核发起读文件请求;内核检查进程的文件描述符定位到系统打开文件列表inode;调用该文件可用的调用函数read();在inode中通过文件内容偏移量计算出要读的页;通过inode找到address_space,访问该文件的页缓存树,找到对应的结点;如果缓存命中,直接返回文件内容;如果不命中,产生缺页中断,创建一个页缓存页,同时通过inode索引块定位到磁盘地址,读入磁盘;返回数据。2 写前4步和读一致,在address_space中查询对应页缓存是否存在:如果命中,直接修改文件内容,然后写结束,此时数据并没有刷回磁盘;如果页缓存不命中,则从磁盘上加载这一页;一个页如果被修改,那么就会标记成脏页,脏页有两种方式刷盘:手动调用sync()或者fsync();pdflush进程会定时刷盘。三、网络IO1 文件层和socket层的关系

inode是文件的元信息,可以对应磁盘上的文件,也可以对应网络连接。IP+port是网络通信地址,而inode是文件系统提供给用户线程读写数据的方式。

struct socket_alloc { struct socket socket; struct inode vfs_inode; };

注意:fd是进程表中open_files的下标,可以拿到file*,一个file可以是Inode、Pipe、Device、Socket等类型。

我们大概畅想下:

客户端和服务端都用 socket 调用创建套接字;服务端用 bind 绑定监听地址,用 listen 把套接字转化为监听套接字,用 accept 捞取一个客户端来的连接;客户端用 connect 进行建连,用 write/read 进行网络 IO;2 源码解析

这就是socket函数返回后的内存结构体。后续我们调用bind,listen等等函数,传入fd,系统就会根据上面图的指向,一直找到tcp函数集,执行对应的函数,对于udp也是一样,不同是tcp函数集变成udp函数集。这一篇我们先介绍socket函数的逻辑,下面继续分析socket编程系列函数的实现。

不管客户端还是服务端都是先创建socket()。

// 新建一个socket结构体,并且创建一个下层的sock结构体,互相关联 static int sock_socket(int family, int type, int protocol) { int i, fd; struct socket *sock; struct proto_ops *ops; // 找到对应的协议族,比如unix域、ipv4 for (i = 0; i < NPROTO; ++i) { // 从props数组中找到family协议对应的操作函数集,props由系统初始化时sock_register进行操作 if (pops[i] == NULL) continue; if (pops[i]->family == family) break; } if (i == NPROTO) { return -EINVAL; } // 函数集 ops = pops[i]; // 检查一下类型 if ((type != SOCK_STREAM && type != SOCK_DGRAM && type != SOCK_SEQPACKET && type != SOCK_RAW && type != SOCK_PACKET) || protocol < 0) return(-EINVAL); // 分配一个新的socket结构体 if (!(sock = sock_alloc())) { ... } // 设置类型和操作函数集 sock->type = type; sock->ops = ops; if ((i = sock->ops->create(sock, protocol)) < 0) { sock_release(sock); return(i); } // 返回一个新的文件描述符 if ((fd = get_fd(SOCK_INODE(sock))) < 0) { sock_release(sock); return(-EINVAL); } return(fd); }struct socket { short type; /* SOCK_STREAM, ... */ socket_state state; long flags; struct proto_ops *ops; // 这个字段要记一下 void *data; struct socket *conn; struct socket *iconn; struct socket *next; struct wait_queue **wait; struct inode *inode; //和socket相互引用 struct fasync_struct *fasync_list; }; struct socket *sock_alloc(void) { struct inode * inode; struct socket * sock; // 获取一个可用的inode节点 inode = get_empty_inode(); if (!inode) return NULL; // 初始化某些字段 inode->i_mode = S_IFSOCK; inode->i_sock = 1;// socket文件 inode->i_uid = current->uid; inode->i_gid = current->gid; // 指向inode的socket结构体,初始化inode结构体的socket结构体 sock = &inode->u.socket_i; sock->state = SS_UNCONNECTED; sock->flags = 0; sock->ops = NULL; sock->data = NULL; sock->conn = NULL; sock->iconn = NULL; sock->next = NULL; sock->wait = &inode->i_wait; // 互相引用 sock->inode = inode; /* "backlink": we could use pointer arithmetic instead */ sock->fasync_list = NULL; // socket数加一 sockets_in_use++; // 返回新的socket结构体,他挂载在inode中 return sock; }// 创建一个sock结构体,和socket结构体互相关联 static int inet_create(struct socket *sock, int protocol) { struct sock *sk; struct proto *prot; int err; // 分配一个sock结构体 sk = (struct sock *) kmalloc(sizeof(*sk), GFP_KERNEL); switch(sock->type) { case SOCK_STREAM: protocol = IPPROTO_TCP; // 函数集 prot = &tcp_prot; break; case SOCK_DGRAM: protocol = IPPROTO_UDP; prot=&udp_prot; break; } // sock结构体的socket字段指向上层的socket结构体 sk->socket = sock; // 省略一堆对sock结构体的初始化代码 }3 listen

分配端口、修改为listen状态,设置接收队列长度

static int sock_listen(int fd, int backlog) { struct socket *sock; if (fd < 0 || fd >= NR_OPEN || current->files->fd[fd] == NULL) return(-EBADF); if (!(sock = sockfd_lookup(fd, NULL))) return(-ENOTSOCK); if (sock->state != SS_UNCONNECTED) { return(-EINVAL); } if (sock->ops && sock->ops->listen) sock->ops->listen(sock, backlog); // 设置socket的监听属性,accept函数时用到 sock->flags |= SO_ACCEPTCON; return(0); } static int inet_listen(struct socket *sock, int backlog) { struct sock *sk = (struct sock *) sock->data; // 如果没有绑定端口则绑定一个,并把sock加到sock_array中 if(inet_autobind(sk)!=0) return -EAGAIN; if ((unsigned) backlog > 128) backlog = 128; // tcp接收队列的长度上限,不同系统实现不一样,具体参考tcp.c的使用 sk->max_ack_backlog = backlog; // 修改socket状态,防止多次调用listen if (sk->state != TCP_LISTEN) { sk->ack_backlog = 0; sk->state = TCP_LISTEN; } return(0); } // 绑定一个随机的端口,更新sk的源端口字段,并把sk挂载到端口对应的队列中,见bind函数的分析 static int inet_autobind(struct sock *sk) { /* We may need to bind the socket. */ if (sk->num == 0) { sk->num = get_new_socknum(sk->prot, 0); if (sk->num == 0) return(-EAGAIN); put_sock(sk->num, sk); sk->dummy_th.source = ntohs(sk->num); } return 0; }4 TCP内部数据结构

tcp内部有一个哈希表保存所有socket,并没有分配实际的资源。每个监听socket有一个backlog,过载会丢包。

// 过载则丢包,防止ddos,max_ack_backlog即listen的参数 if (sk->ack_backlog >= sk->max_ack_backlog) { tcp_statistics.TcpAttemptFails++; kfree_skb(skb, FREE_READ); return; }3 总结vfs 下有一个 sockfs 的抽象层,是把 socket 抽象成“文件” fd 的关键之一;socket fd 能够和文件 IO 一样,使用 write/read 等系统调用,就得益于 vfs 帮你做的转接。那 socket() 函数调用是不是就和 open 文件 fd 的效果是一样的呀?是的,都是构建并关联各种内核结构体;epoll 池能管理 socketfd,因为 socket fd 实现 poll 接口;epoll_ctl 注册 socket fd 的时候,挂了个 wait 对象在 socket 的 sk_wq 里,所以数据就绪的时候,socket 才能通知到 epoll;epoll_wait 切走的时候挂了个 wait 对象在 epoll 上,所以 epoll 就绪的时候,才能有机会唤醒阻塞的线程;套接字由 socket() 创建出来,客户端和服务端都是,listen() 调用可以把套接字转化成监听套接字;监听套接字一般只监听可读事件,关注连接的建立,普通套接字走数据流,关注数据的读写事件; 4 问题

server端遇到连接数量太多,无法打开新连接? 1. 一般是文件句柄数量太多,达到上限,并不是端口耗尽。 2. backlog接收队列已满,丢包

5 接收队列

socket有两个队列:半连接队列、全连接队列,两者长度没有必然联系,半连接队列是在/proc/sys/net/ipv4/tcp_max_syn_backlog,而全连接队列长度是在调用listen()函数时设置的。

如果客户端连接失败,有可能是半连接被打满,也有可能是全连接被打满。

5.1 半连接

半连接队列被打满可能是SYN Flood攻击,此时应该采用首包丢弃和源认证来解决。

SYN Flood攻击是生成无数个虚假地址来通信,一般地址会不断变化,不会应答的。

首包丢弃看是否能够超时重传,如果能,初步认为是正常的用户地址。

然后由Anti-DDoS系统代替服务器向客户端发送SYN-ACK报文,如果客户端不应答,则认为该客户端为虚假源;如果客户端应答,则Anti-DDoS系统认为该客户端为真实源,并将其IP地址加入白名单,在一段时间允许该源发送的所有SYN报文通过,也不做代答。

四、tcp连接

短连接的操作步骤是: 建立连接——数据传输——关闭连接…建立连接——数据传输——关闭连接 长连接的操作步骤是: 建立连接——数据传输…(保持连接)…数据传输——关闭连接

三次握手四次挥手1 tcp keep-alive

传输层保活机制

tcp具有保活功能,当tcp服务端回复之后会开启保活定时器,时间一到就会发送探测报文,重复10次后没有得到响应,则关闭连接。

2 应用层保活机制

以netty举例,通过IdleStateHandler来保活。

2.1 client@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { // send heartbeat when read idle. if (evt instanceof IdleStateEvent) { try { NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler); if (logger.isDebugEnabled()) { logger.debug("IdleStateEvent triggered, send heartbeat to channel " + channel); } Request req = new Request(); req.setVersion(Version.getProtocolVersion()); req.setTwoWay(true); req.setEvent(HEARTBEAT_EVENT); channel.send(req);//定时发送心跳 } finally { NettyChannel.removeChannelIfDisconnected(ctx.channel()); } } else { super.userEventTriggered(ctx, evt); } }2.2 serverpublic class NettyServerHandler extends ChannelDuplexHandler { private static final Logger logger = LoggerFactory.getLogger(NettyServerHandler.class); /** * the cache for alive worker channel. * */ private final Map channels = new ConcurrentHashMap(); private final URL url; private final ChannelHandler handler; @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { // server will close channel when server don't receive any heartbeat from client util timeout. if (evt instanceof IdleStateEvent) { NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler); try { logger.info("IdleStateEvent triggered, close channel " + channel); //关闭 channel.close(); } finally { NettyChannel.removeChannelIfDisconnected(ctx.channel()); } } super.userEventTriggered(ctx, evt); } }2.3 IdleStateHandler

该类会开启心跳定时器,如果超时,会立刻注册一个IdleStateEvent

private void initialize(ChannelHandlerContext ctx) { // Avoid the case where destroy() is called before scheduling timeouts. // See: https://github.com/netty/netty/issues/143 switch (state) { case 1: case 2: return; default: break; } state = 1; initOutputChanged(ctx); //开启定时器, //客户端每过心跳间隔就立刻发送心跳。 //服务端定时扫描连接上次读写的时间,如果超时则关闭。 lastReadTime = lastWriteTime = ticksInNanos(); if (readerIdleTimeNanos > 0) { readerIdleTimeout = schedule(ctx, new ReaderIdleTimeoutTask(ctx), readerIdleTimeNanos, TimeUnit.NANOSECONDS); } if (writerIdleTimeNanos > 0) { writerIdleTimeout = schedule(ctx, new WriterIdleTimeoutTask(ctx), writerIdleTimeNanos, TimeUnit.NANOSECONDS); } if (allIdleTimeNanos > 0) { allIdleTimeout = schedule(ctx, new AllIdleTimeoutTask(ctx), allIdleTimeNanos, TimeUnit.NANOSECONDS); } }3 总结

长连接特点 1. 复用连接,可以减少连接创建和释放的开销,适用于客户端比较稳定的场景。 2. 会一直占用文件句柄,需要保活机制及时释放掉断连的连接。

短连接特点 1. 连接不会复用,每次请求都需要建立和拆除连接,性能较差,适用于客户端不稳定、请求频率较低的场景。 2. 很容易出现端口被占满,主动断开方会出现大量TIME_WAIT状态的tcp连接,只有等待2MSL才会关闭,如果服务端是主动断开连接,端口很快就会耗尽,可设置SO_RESUSEADDR来端口复用。

tcp保活机制在内核实现,不太适应应用层,不区分长连接和短连接。可能因为应用层导致无法及时响应请求,但连接还是正常的。

4 tcp常见问题4.1 为什么关闭连接需要四次挥手,建立连接却只需要3次握手

关闭连接时,被动断开方可能还有数据没传输完,不能立即断开连接,只能回复一个ACK响应主动断开方的FIN报文。而建立连接时,为了提高效率,被动方将ACK报文和自己的SYN报文合并成SYN+ACK报文,减少一次握手。

4.2 为什么连接建立的时候是三次握手,能否改成两次握手

不能,第一次握手是主动方SYN请求,第二次握手是被动方的SYN+ACK请求,如果少了第三次握手,就无法对被动方的SYN报文进行确认,无法确保连接是否正常建立。四次握手是可以的,但是为了效率考虑,被动方将ACK报文和自己的SYN报文合并成SYN+ACK报文,减少一次握手。

4.3 为什么主动断开方在TIME_WAIT状态必须等待2MSL

一:被动断开方发送FIN报文后,主动断开方响应ACK报文,但是ACK报文可能会丢失,被动断开方无法顺利进入CLOSE状态,就会超时重传。

二:主动断开方需要等待2MSL,意味着端口要在2MSL后才能被新连接使用。2MSL时间后,旧连接所产生的报文已经从网络中消失了,确保新连接诶不会出现旧连接的报文。

4.4 如果已经建立了连接,但是client突然出现故障了怎么办

TCP设有保活计时器,每收到一次client的数据帧后,server就会将保活计时器复位。计时器的超时时间一般设置为2h,若2h内没有收到client的数据帧,server就会发送探测报文,以后每隔75s发送一次,10次后没有响应,则认为client故障,关闭连接。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3