从Linux源码看Socket(TCP)Client端的Connect

前言

笔者一直觉得如果能知道从应用到框架再到操作系统的每一处代码,是一件Exciting的事情。
今天笔者就来从Linux源码的角度看下Client端的Socket在进行Connect的时候到底做了哪些事情。由于篇幅原因,关于Server端的Accept源码讲解留给下一篇博客。
(基于Linux 3.10内核)

一个最简单的Connect例子

int clientSocket;if((clientSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {    //  创建socket失败失败     return -1;}......if(connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {    // connect 失败    return -1;}.......

首先我们通过socket系统调用创建了一个socket,其中指定了SOCK_STREAM,而且最后一个参数为0,也就是建立了一个通常所有的TCP Socket。在这里,我们直接给出TCP Socket所对应的ops也就是操作函数。
如果你想知道上图中的结构是怎么来的,可以看下笔者以前的博客:

https://my.oschina.net/alchemystar/blog/1791017

值得注意的是,由于socket系统调用操作做了如下两个代码的判断

sock_map_fd    |->get_unused_fd_flags            |->alloc_fd                |->expand_files (ulimit)    |->sock_alloc_file        |->alloc_file            |->get_empty_filp (/proc/sys/fs/max_files)

第一个判断,ulmit超限:

int expand_files(struct files_struct *files, int nr{    ......    if (nr >= current->signal->rlim[RLIMIT_NOFILE].rlim_cur)        return -EMFILE;    ......}

这边的判断即是ulimit的限制!在这里返回-EMFILE对应的描述就是
“Too many open files”

第二个判断max_files超限

struct file *get_empty_filp(void){ ......    /*     * 由此可见,特权用户可以无视文件数最大大小的限制!     */    if (get_nr_files() >= files_stat.max_files && !capable(CAP_SYS_ADMIN)) {        /*         * percpu_counters are inaccurate.  Do an expensive check before         * we go and fail.         */        if (percpu_counter_sum_positive(&nr_files) >= files_stat.max_files)            goto over;    }

 ......}

所以在文件描述符超过所有进程能打开的最大文件数量限制(/proc/sys/fs/file-max)的时候会返回-ENFILE,对应的描述就是”Too many open files in system”,但是特权用户确可以无视这一限制,如下图所示:

connect系统调用

我们再来看一下connect系统调用:

int connect(int sockfd,const struct sockaddr *serv_addr,socklen_t addrlen)

这个系统调用有三个参数,那么依据规则,它肯定在内核中的源码长下面这个样子

SYSCALL_DEFINE3(connect, ......

笔者全文搜索了下,就找到了具体的实现:

socket.cSYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,        int, addrlen){   ......    err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,                 sock->file->f_flags);    ......}

前面图给出了在TCP下的sock->ops == inet_stream_ops,然后再陷入到更进一步的调用栈中,即下面的:

SYSCALL_DEFINE3(connect    |->inet_stream_ops        |->inet_stream_connect            |->tcp_v4_connect                |->tcp_set_state(sk, TCP_SYN_SENT);设置状态为TCP_SYN_SENT                 |->inet_hash_connect                |->tcp_connect

首先,我们来看一下inet_hash_connect这个函数,里面有一个端口号的搜索过程,搜索不到可用端口号就会导致创建连接失败!内核能够建立一个连接也是跋涉了千山万水的!我们先看一下搜索端口号的逻辑,如下图所示:

获取端口号范围

首先,我们从内核中获取connect能够使用的端口号范围,在这里采用了Linux中的顺序锁(seqlock)

void inet_get_local_port_range(int *low, int *high){    unsigned int seq;

    do {        // 顺序锁        seq = read_seqbegin(&sysctl_local_ports.lock);

        *low = sysctl_local_ports.range[0];        *high = sysctl_local_ports.range[1];    } while (read_seqretry(&sysctl_local_ports.lock, seq));}

顺序锁事实上就是结合内存屏障等机制的一种乐观锁,主要依靠一个序列计数器。在读取数据之前和之后,序列号都被读取,如果两者的序列号相同,说明在读操作的时候没有被写操作打断过。
这也保证了上面的读取变量都是一致的,也即low和high不会出现low是改前值而high是改后值得情况。low和high要么都是改之前的,要么都是改之后的!内核中修改的地方为:

cat /proc/sys/net/ipv4/ip_local_port_range32768 61000

通过hash决定端口号起始搜索范围

在Linux上进行connect,内核给其分配的端口号并不是线性增长的,但是也符合一定的规律。
先来看下代码:

int __inet_hash_connect(...){        // 注意,这边是static变量        static u32 hint;        // 这边的port_offset是用对端ip:port hash的一个值        // 也就是说对端ip:port固定,port_offset固定        u32 offset = hint + port_offset;        for (i = 1; i <= remaining; i++) {            port = low + (i + offset) % remaining;            /* port是否占用check */            ....            goto ok;        }        .......ok:        hint += i;        ......}

这里面有几个小细节,为了安全原因,Linux本身用对端ip:port做了一次hash作为搜索的初始offset,所以不同远端ip:port初始搜索范围可以基本是不同的!但同样的对端ip:port初始搜索范围是相同的!
在笔者机器上,一个完全干净的内核里面,不停的对同一个远端ip:port,其以2进行稳定增长,也即38742->38744->38746,如果有其它的干扰,就会打破这个规律。

端口号范围限制

由于我们指定了端口号返回ip_local_port_range是不是就意味着我们最多创建high-low+1个连接呢?当然不是,由于检查端口号是否重复是将(网络命名空间,对端ip,对端port,本端port,Socket绑定的dev)当做唯一键进行重复校验,所以限制仅仅是在同一个网络命名空间下,连接同一个对端ip:port的最大可用端口号数为high-low+1,当然可能还要减去ip_local_reserved_ports。如下图所示:

检查端口号是否被占用

端口号的占用搜索分为两个阶段,一个是处于TIME_WAIT状态的端口号搜索,另一个是其它状态端口号搜索。

TIME_WAIT状态端口号搜索

众所周知,TIME_WAIT阶段是TCP主动close必经的一个阶段。如果Client采用短连接的方式和Server端进行交互,就会产生大量的TIME_WAIT状态的Socket。而这些Socket由占用端口号,所以当TIME_WAIT过多,打爆上面的端口号范围之后,新的connect就会返回错误码:

C语言connect返回错误码为-EADDRNOTAVAIL,对应描述为Cannot assign requested address对应Java的异常为java.net.NoRouteToHostException: Cannot assign requested address (Address not available)

ip_local_reserved_ports。如下图所示:
由于TIME_WAIT大概一分钟左右才能消失,如果在一分钟内Client端和Server建立大量的短连接请求就容易导致端口号耗尽。而这个一分钟(TIME_WAIT的最大存活时间)是在内核(3.10)编译阶段就确定了的,无法通过内核参数调整。如下代码所示:

#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT                  * state, about 60 seconds    */

Linux自然也考虑到了这种情况,所以提供了一个tcp_tw_reuse参数使得在搜索端口号时可以在某些情况下重用TIME_WAIT。代码如下:

__inet_hash_connect    |->__inet_check_establishedstatic int __inet_check_established(......){    ......    /* Check TIME-WAIT sockets first. */    sk_nulls_for_each(sk2, node, &head->twchain) {        tw = inet_twsk(sk2);        // 如果在time_wait中找到一个match的port,就判断是否可重用        if (INET_TW_MATCH(sk2, net, hash, acookie,                    saddr, daddr, ports, dif)) {            if (twsk_unique(sk, sk2, twp))                goto unique;            else                goto not_unique;        }    }    ......}

如上面代码中写的那样,如果在一堆TIME-WAIT状态的Socket里面能够有当前要搜索的port,则判断是否这个port可以重复利用。如果是TCP的话这个twsk_unique的实现函数是:

int tcp_twsk_unique(......){    ......    if (tcptw->tw_ts_recent_stamp &&        (twp == NULL || (sysctl_tcp_tw_reuse &&                 get_seconds() - tcptw->tw_ts_recent_stamp > 1))) {        tp->write_seq = tcptw->tw_snd_nxt + 65535 + 2        ......        return 1;    }    return 0;}

上面这段代码逻辑如下所示:
在开启了tcp_timestamp以及tcp_tw_reuse的情况下,在Connect搜索port时只要比之前用这个port的TIME_WAIT状态的Socket记录的最近时间戳>1s,就可以重用此port,即将之前的1分钟缩短到1s。同时为了防止潜在的序列号冲突,直接将write_seq加上在65537,这样,在单Socket传输速率小于80Mbit/s的情况下,不会造成序列号冲突。
同时这个tw_ts_recent_stamp设置的时机如下图所示:
所以如果Socket进入TIME_WAIT状态后,如果一直有对应的包发过来,那么会影响此TIME_WAIT对应的port是否可用的时间。我们可以通过下面命令开始tcp_tw_reuse:

echo '1' > /proc/sys/net/ipv4/tcp_tw_reuse

ESTABLISHED状态端口号搜索

ESTABLISHED的端口号搜索就简单了许多

    /* And established part... */    sk_nulls_for_each(sk2, node, &head->chain) {        if (INET_MATCH(sk2, net, hash, acookie,                    saddr, daddr, ports, dif))            goto not_unique;    }

以(网络命名空间,对端ip,对端port,本端port,Socket绑定的dev)当做唯一键进行匹配,如果匹配成功,表明此端口无法重用。

端口号迭代搜索

Linux内核在[low,high]范围按照上述逻辑进行port的搜索,如果没有搜索到port,即port耗尽,就会返回-EADDRNOTAVAIL,也即Cannot assign requested address。但还有一个细节,如果是重用TIME_WAIT状态的Socket的端口的话,就会将对应的TIME_WAIT状态的Socket给销毁。

__inet_hash_connect(......){        ......        if (tw) {            inet_twsk_deschedule(tw, death_row);            inet_twsk_put(tw);        }        ......}

寻找路由表

在我们找到一个可用端口号port后,就会进入搜寻路由阶段:

ip_route_newports    |->ip_route_output_flow            |->__ip_route_output_key                |->ip_route_output_slow                    |->fib_lookup

这也是一个非常复杂的过程,限于篇幅,就不做详细阐述了。如果搜索不到路由信息的话,会返回。

-ENETUNREACH,对应描述为Network is unreachable

Client端的三次握手

在前面一大堆前置条件就绪后,才进入到真正的三次握手阶段。

tcp_connect    |->tcp_connect_init 初始化tcp socket    |->tcp_transmit_skb 发送SYN包    |->inet_csk_reset_xmit_timer 设置SYN重传定时器

tcp_connect_init初始化了一大堆TCP相关的设置,例如mss_cache/rcv_mss等一大堆。而且如果开启了TCP窗口扩大选项的话,其窗口扩大因子也在此函数里进行计算:

tcp_connect_init    |->tcp_select_initial_windowint tcp_select_initial_window(...){    ......    (*rcv_wscale) = 0;    if (wscale_ok) {        /* Set window scaling on max possible window         * See RFC1323 for an explanation of the limit to 14         */        space = max_t(u32, sysctl_tcp_rmem[2], sysctl_rmem_max);        space = min_t(u32, space, *window_clamp);        while (space > 65535 && (*rcv_wscale) < 14) {            space >>= 1;            (*rcv_wscale)++;        }    }    ......}

如上面代码所示,窗口扩大因子取决于Socket最大可允许的读缓冲大小和window_clamp(最大允许滑动窗口大小,动态调整)。搞完了一票初始信息设置后,才开始真正的三次握手。
在tcp_transmit_skb中才真正发送SYN包,同时在紧接着的inet_csk_reset_xmit_timer里设置了SYN超时定时器。如果对端一直不发送SYN_ACK,将会返回-ETIMEDOUT。
重传的超时时间和

/proc/sys/net/ipv4/tcp_syn_retries

息息相关,Linux默认设置为5,建议设置成3,下面是不同设置的超时时间参照图。
在设置了SYN超时重传定时器后,tcp_connnect就返回,并一路返回到最初始的inet_stream_connect。在这里我们就等待对端返回SYN_ACK或者SYN定时器超时。

int __inet_stream_connect(struct socket *sock,...,){    // 如果设置了O_NONBLOCK则timeo为0    timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);    ......    // 如果timeo=0即O_NONBLOCK会立刻返回    // 否则等待timeo时间    if (!timeo || !inet_wait_for_connect(sk, timeo, writebias))        goto out;}

Linux本身提供一个SO_SNDTIMEO来控制对connect的超时,不过Java并没有采用这个选项。而是采用别的方式进行connect的超时控制。仅仅就C语言的connect系统调用而言,不设置SO_SNDTIMEO,就会将对应用户进程进行睡眠,直到SYN_ACK到达或者超时定时器超时才将次用户进程唤醒。
如果是NON_BLOCK的话,则是通过select/epoll等多路复用机制去捕获超时或者连接成功事件。

对端SYN_ACK到达

在Server端SYN_ACK到达之后会按照下面的代码路径传递,并唤醒用户态进程:

tcp_v4_rcv    |->tcp_v4_do_rcv        |->tcp_rcv_state_process            |->tcp_rcv_synsent_state_process                |->tcp_finish_connect                    |->tcp_init_metrics 初始化度量统计                    |->tcp_init_congestion_control 初始化拥塞控制                    |->tcp_init_buffer_space 初始化buffer空间                    |->inet_csk_reset_keepalive_timer 开启包活定时器                    |->sk_state_change(sock_def_wakeup) 唤醒用户态进程                |->tcp_send_ack 发送三次握手的最后一次握手给Server端            |->tcp_set_state(sk, TCP_ESTABLISHED) 设置为ESTABLISHED状态

总结

Client(TCP)端进行Connect的过程真是跋山涉水,从一开始文件描述符的限制到端口号的搜索再到路由表的搜索再到最后的三次握手,任何一个环节有问题就会导致创建连接失败,笔者详细的描述了这些机制的源码实现。希望本篇文章可以对读者在以后遇到Connect失败问题时候有所帮助。

codeblock socket 编译错误_从Linux源码看Socket(TCP)Client端的Connect相关推荐

  1. linux内核线程socket,从Linux源码看Socket(TCP)的accept

    从Linux源码看Socket(TCP)的accept 前言 笔者一直以为若是能知道从应用到框架再到操做系统的每一处代码,是一件Exciting的事情. 今天笔者就从Linux源码的角度看下Serve ...

  2. java linux 调用32位so_从linux源码看socket(tcp)的timeout

    从linux源码看socket(tcp)的timeout 前言 网络编程中超时时间是一个重要但又容易被忽略的问题,对其的设置需要仔细斟酌.在经历了数次物理机宕机之后,笔者详细的考察了在网络编程(tcp ...

  3. 从Linux源码看Socket(TCP)的listen及连接队列

    从Linux源码看Socket(TCP)的listen及连接队列 前言 笔者一直觉得如果能知道从应用到框架再到操作系统的每一处代码,是一件Exciting的事情. 今天笔者就来从Linux源码的角度看 ...

  4. 从Linux源码看Socket(TCP)的bind

    从Linux源码看Socket(TCP)的bind 前言 笔者一直觉得如果能知道从应用到框架再到操作系统的每一处代码,是一件Exciting的事情. 今天笔者就来从Linux源码的角度看下Server ...

  5. 从 Linux 源码看 Socket 的阻塞和非阻塞

    转载自 从 Linux 源码看 Socket 的阻塞和非阻塞 笔者一直觉得如果能知道从应用到框架再到操作系统的每一处代码,是一件Exciting的事情. 大部分高性能网络框架采用的是非阻塞模式.笔者这 ...

  6. linux源码_从linux源码看epoll及epoll实战揭秘

    从linux源码看epoll 前言 在linux的高性能网络编程中,绕不开的就是epoll.和select.poll等系统调用相比,epoll在需要监视大量文件描述符并且其中只有少数活跃的时候,表现出 ...

  7. close wait 过多原因_从Linux源码看TIME_WAIT状态的持续时间

    前言 笔者一直以为在Linux下TIME_WAIT状态的Socket持续状态是60s左右.线上实际却存在TIME_WAIT超过100s的Socket.由于这牵涉到最近出现的一个复杂Bug的分析.所以, ...

  8. java sotimeout tcp_从linux源码看socket(tcp)的timeout

    前言 网络编程中超时时间是一个重要但又容易被忽略的问题,对其的设置需要仔细斟酌.在经历了数次物理机宕机之后,笔者详细的考察了在网络编程(tcp)中的各种超时设置,于是就有了本篇博文.本文大部分讨论的是 ...

  9. ip受限 linux_从linux源码看epoll及epoll实战揭秘

    从linux源码看epoll 前言 在linux的高性能网络编程中,绕不开的就是epoll.和select.poll等系统调用相比,epoll在需要监视大量文件描述符并且其中只有少数活跃的时候,表现出 ...

最新文章

  1. 在撤销“本地修改”之后再恢复
  2. (转载)自然语言处理中的Attention Model:是什么及为什么
  3. FLV Extract 1.2.1
  4. DataBinding 学习系列(2)详解DataBinding在xml中的使用
  5. 让ubuntu开机快一点:记开机出现Waiting for network configuration...
  6. C程序设计--排序(冒泡、选择、插入)--选择
  7. 服务器 2.5 英寸硬盘托架安装
  8. 2012年1月份第3周51Aspx源码发布详情
  9. 安全工具之hackingtool
  10. 论文 图片配色 漂亮颜色
  11. python利用matplotlib库绘制三维图学习
  12. maven安装以及本地创库设置
  13. H. Holy Grail(The Preliminary Contest for ICPC Asia Nanjing 2019题解)
  14. mysql 授权root帐号可以远程访问,导入sql文件,登录mysql用root帐号,设置密码
  15. 从kernel源码进阶C语言
  16. 微信7.0.10正式版来了!朋友圈斗图彻底关闭了!
  17. Android 常见的透明度颜色值ARGB 半透明 全透明
  18. 42个面向前端开发人员的很棒JavaScript 库和框架
  19. ibm r40的内存品牌_在IBM Business Process Manager中品牌化和定制教练主题
  20. 【R语言】期末考试五道题

热门文章

  1. C# Hook原理及EasyHook简易教程
  2. jquery实现回车键触发事件
  3. .NET 4.0 任务(Task)
  4. 获取工作流活动的返回值
  5. mysql 小数点后几位不足补0初始化
  6. Redis为什么是单线程还这么快?
  7. soapui模拟桩mockservice---模拟后台服务器
  8. 分布式数据库中间件Mycat百亿级数据存储(转)
  9. localStorage的过期时间设置的方法?
  10. Docker与虚拟机