前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Linux网络连接原理

Linux网络连接原理

原创
作者头像
冰寒火
发布2023-03-01 15:43:42
1.8K0
发布2023-03-01 15:43:42
举报
文章被收录于专栏:软件设计软件设计

一、作用

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

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

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

代码语言:c++
复制
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 读

  1. 进程调用库函数向内核发起读文件请求;
  2. 内核检查进程的文件描述符定位到系统打开文件列表inode;
  3. 调用该文件可用的调用函数read();
  4. 在inode中通过文件内容偏移量计算出要读的页;
  5. 通过inode找到address_space,访问该文件的页缓存树,找到对应的结点;
    1. 如果缓存命中,直接返回文件内容;
    2. 如果不命中,产生缺页中断,创建一个页缓存页,同时通过inode索引块定位到磁盘地址,读入磁盘;
  6. 返回数据。

2 写

  1. 前4步和读一致,在address_space中查询对应页缓存是否存在:
    1. 如果命中,直接修改文件内容,然后写结束,此时数据并没有刷回磁盘;
    2. 如果页缓存不命中,则从磁盘上加载这一页;
  2. 一个页如果被修改,那么就会标记成脏页,脏页有两种方式刷盘:
    1. 手动调用sync()或者fsync();
    2. pdflush进程会定时刷盘。

三、网络IO

1 文件层和socket层的关系

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

代码语言:c++
复制
struct socket_alloc {  
    struct socket socket;  
    struct inode vfs_inode;  
}; 

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

我们大概畅想下:

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

2 源码解析

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

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

代码语言:c++
复制
// 新建一个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);
}
代码语言:c++
复制
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;
}
代码语言:c++
复制
// 创建一个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状态,设置接收队列长度

代码语言:c
复制
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,过载会丢包。

代码语言:c
复制
// 过载则丢包,防止ddos,max_ack_backlog即listen的参数
    if (sk->ack_backlog >= sk->max_ack_backlog) 
    {
        tcp_statistics.TcpAttemptFails++;
        kfree_skb(skb, FREE_READ);
        return;
    }

3 总结

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

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

代码语言:java
复制
@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 server

代码语言:java
复制
public class NettyServerHandler extends ChannelDuplexHandler {
    private static final Logger logger = LoggerFactory.getLogger(NettyServerHandler.class);
    /**
     * the cache for alive worker channel.
     * <ip:port, dubbo channel>
     */
    private final Map<String, Channel> 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

代码语言:java
复制
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故障,关闭连接。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、作用
  • 二、进程和虚拟文件系统的交互
    • 1 读
      • 2 写
      • 三、网络IO
        • 1 文件层和socket层的关系
          • 2 源码解析
            • 3 listen
            • 4 TCP内部数据结构
          • 3 总结
            • 4 问题
              • 5 接收队列
                • 5.1 半连接
            • 四、tcp连接
              • 1 tcp keep-alive
                • 2 应用层保活机制
                  • 2.1 client
                  • 2.2 server
                  • 2.3 IdleStateHandler
                • 3 总结
                  • 4 tcp常见问题
                    • 4.1 为什么关闭连接需要四次挥手,建立连接却只需要3次握手
                    • 4.2 为什么连接建立的时候是三次握手,能否改成两次握手
                    • 4.3 为什么主动断开方在TIME_WAIT状态必须等待2MSL
                    • 4.4 如果已经建立了连接,但是client突然出现故障了怎么办
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档