ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解Linux网络编程:bind与connect的底层原理与故障排查

深入理解Linux网络编程:bind与connect的底层原理与故障排查 1. 先搞清楚bind是服务端的事connect是客户端的事在Linux网络编程里每当我带新人写第一个socket程序总会看到类似的问题明明照着示例代码敲完了socket、bind、listen、accept、connect代码也能跑通但问一句bind到底做了什么connect又做了什么大部分人开始支支吾吾。这不怪大家因为很多教程都是直接甩代码根本不解释两个函数的角色分工和底层机制。这次我打算把connect和bind这两个过程掰开揉碎讲清楚。先说结论bind是挂牌connect是拨号。服务端进程必须先用bind把自己的IP和端口登记到内核相当于在一栋楼门口挂上402室某某公司的门牌这样别人才能准确找到你而connect是客户端主动出击的动作相当于拿起电话拨号通过门牌找到目标然后双方建立起一条可以通话的通道。两者一个被动等待一个主动发起配合listen和accept才构成了一次完整的TCP连接建立流程。这个知识适合谁我认为不只是搞C/C网络编程的人需要理解写Java、Go、Python后端服务的排查线上连接问题的运维甚至是做嵌入式网络开发的朋友都值得把这两个函数从会用提升到懂它的程度。因为很多线上故障——连接超时、端口被拒、资源耗尽归根结底都是这两个过程中的某一环出了问题。1.1 一次网络通信的两端视角想象一下两个人打电话服务端是那个24小时守在电话旁等铃声响起的人他必须先把电话号码申请好、把电话线接好然后坚决不能离开客户端是那个想咨询问题的人拿起话筒拨号、等待接通、开始对话。从这个类比延伸出两个关键点。第一两端做事的顺序完全不同服务端一定是先创建套接字、绑定地址、监听、再接受连接客户端则是创建套接字之后直接发起连接。第二bind在客户端程序中几乎不会出现除非有特殊需求比如固定源端口、源IP但对服务端来说bind却是listen之前躲不过去的一步。很多初学者会在这里产生一个疑问我用Python的Flask写Web服务时也没有手动bind过啊为什么就能直接跑起来因为框架帮你做了。Flask底层调用的是Python标准库的socket然后自动执行了bind((0.0.0.0, 5000))这类操作。框架解决的是易用性问题底层逻辑并没有变。1.2 bind和connect各自完成了什么从系统调用的角度来看bind的函数签名是int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);它的核心作用是把addr指向的本地IP地址和端口号与sockfd这个套接字绑定在一起。完成了这一步内核就知道这个套接字应该接收发往哪个IP、哪个端口的数据。而connect的函数签名是int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);它的作用就不只是记住地址那么简单了。对TCP套接字来说connect会真正向对端发送SYN报文触发三次握手对UDP套接字来说connect则像在邮局登记了一个默认收件人之后你sendto时可以不写地址直接send就行而且内核还会帮你过滤掉来自其他地址的数据包。这两个函数名字起得很有意思。bind本意是捆绑把套接字和地址捆在一起告诉内核这个套接字是哪块地盘的老大connect本意是连接把一个原本孤零零的套接字通过地址找到远方的另一个套接字并建立联系。一个向内管住自己一个向外寻找对方。1.3 新手和高手的视角差异新手看到bind觉得它只不过是把端口填进去高手看到bind会立刻联想到EADDRINUSE、SO_REUSEADDR、通配地址、临时端口这些问题。新手看到connect以为它发个包等回复就行高手看到connect会自动脑补出SYN重传、临时端口分配、路由表选择、非阻塞超时处理这一整套流程。驱动这种差异的是对内核在这两个调用里做了什么的理解程度。下面我分别把bind和connect的过程完整展开每一步都有代码、有原理、有排障手段。2. bind过程详解把套接字钉在本地地址上bind这一步说白了就是给套接字上户口。你写服务器程序的时候如果跳过了bind直接调用listen内核会直接返回EINVAL。原因很简单一个没有绑定任何地址的套接字就像一个没有门牌号的房间你说准备好接待客人了但客人根本找不到你这显然不合理。2.1 bind之前套接字是什么状态socket()创建出来的套接字在内核里只是一个独立的协议对象。它知道自己属于哪个协议族AF_INET、什么类型SOCK_STREAM知道自己应该使用TCP还是UDP协议栈但它不知道自己代表哪个IP、哪个端口。就像一个刚出生的人有身份证号但还没上户口、没住址内核不知道该把他发送的数据挂到哪个地址名下也不知道发往哪个地址的数据该交给他处理。此时你去看系统里的套接字状态会看到一个很奇怪的输出$ ss -tlnp State Local Address:Port Peer Address:Port如果套接字还没bindLocal Address这一列通常是空的或者显示*:*。这说明它还没有本地地址无法被任何人找到。2.2 三件套协议族、IP、端口bind函数需要传入的结构体对IPv4来说就是struct sockaddr_instruct sockaddr_in { sa_family_t sin_family; // 协议族必须填 AF_INET in_port_t sin_port; // 端口号网络字节序 struct in_addr sin_addr; // IP地址网络字节序 };这里有一个坑也是我在代码评审里反复提到的sin_port和sin_addr都必须是网络字节序也就是大端字节序。多数服务器是x86架构属于小端字节序所以你直接赋值是错的必须用htons()和htonl()做转换。新手经常漏掉这个转换函数结果bind完之后自己用netstat查端口发现端口变成了一堆奇怪的数字怎么都对不上。写完结构体之后建议用memset先清零整个结构体再逐字段赋值避免栈上的脏数据污染地址结构。这一点看很多开源项目的代码都能发现这个固定套路不是洁癖是确实能避免偶发的诡异问题。2.3 四种绑定组合端口0什么时候用bind时的IP和端口可以自由组合一共四种情况。我整理了一张表这样看起来更直观组合方式含义典型场景IP指定 端口指定绑定到特定的IP和端口服务器有多个网卡只想对外提供某一个网卡的服务IP通配 端口指定绑定所有网卡监听指定端口最常见Node.js的app.listen(3000)其实就是这样IP指定 端口0绑定到特定IP端口由内核随机分配客户端程序需要指定源IP时IP通配 端口0绑定所有网卡端口随机分配客户端程序或者RPC框架的临时监听端口填0是很多人不理解的地方。这是因为内核允许应用层不指定端口它在bind的时候会从系统配置的临时端口范围里挑一个空闲端口用。这个机制对客户端特别重要如果你的客户端每次connect都不主动bind内核就会用这种通配IP 端口0的方式自动帮你选好本地地址。后面讲connect的时候会细说。2.4 bind底层到底检查了什么bind表面上是把地址写进套接字但内核在这个系统调用里做了一堆检查每项检查失败都对应一个明确错误码检查端口是否被其他套接字占用。如果占用返回EADDRINUSE。这时你如果没设置SO_REUSEADDR一些处于TIME_WAIT状态的连接也可能导致绑定失败。检查绑定端口是否小于1024。普通用户没权限绑定这些特权端口内核返回EACCES。检查传入的IP地址是否属于本机某个网卡。如果IP根本不属于本机返回EADDRNOTAVAIL。当然绑0.0.0.0是例外这代表所有网卡。检查套接字是否已经bind过。如果重复bind返回EINVAL。所以你在代码里看到bind失败别急着怀疑玄学先对照错误码定位。nodejs的EADDRINUSE报错、Nginx的bind() to 0.0.0.0:80 failed (13: Permission denied)本质都是这些检查触发了。2.5 实测bind报错与SO_REUSEADDR我写一段最典型的服务器启动代码演示bind的完整使用import socket import sys s socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 不加下面这行的话重启服务器大概率报 Address already in use s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind((0.0.0.0, 8080)) except OSError as e: print(fbind failed: {e}) sys.exit(1) s.listen(5) print(server listening on 8080)有朋友问过为什么服务器重启之后马上bind会失败原因是前一个进程正在处理连接进程被杀掉后连接处于TIME_WAIT状态还没彻底消失。内核认为这个端口依然被之前那条连接占用着于是禁止新bind。SO_REUSEADDR的作用是允许新套接字bind到这个处于TIME_WAIT的端口上。这个选项在服务器程序里几乎是标配尤其是需要频繁重启的服务。但注意如果端口上有一个ESTABLISHED状态的活跃连接SO_REUSEADDR也救不了你你该报EADDRINUSE还是报。我见过有人拿SO_REUSEADDR当万能药结果线上服务重启失败查了半天发现是上一个进程没有完全退出端口还在被存活进程持有。这种情况用ss -lntp一看就能现原形。3. connect过程详解客户端主动出击写完服务端的bind接下来讲connect。connect在代码里往往只是一行调用但这一行的背后内核做了至少三件事准备本地地址、查找匹配远端地址、发送SYN触发握手。这个过程比bind复杂得多也更容易出故障。3.1 connect的三步动作第一步如果套接字还没有绑定本地地址大多数客户端套接字都没有bind过内核要挑选一个合适的源IP和源端口。源IP根据路由表决定比如你要连接一个公网IP路由表会让你走eth0的IP作为源地址源端口从临时端口范围里挑一个空闲的。第二步解析和匹配远端地址。connect传入的sockaddr_in包含目标IP和端口内核根据协议族、目标IP是否可路由、目标端口是否合法做一次预检。如果目标地址不可达或者协议族不匹配这里就会直接出错。第三步构造并发送SYN报文启动三次握手。这一步对TCP来说是最关键的因为connect的返回时机跟握手结果强相关。3.2 三次握手在connect里的位置教科书式的三次握手是客户端发送SYN告诉服务器我想建立连接。服务器收到SYN回复SYNACK表示我收到了我也准备好了。客户端收到SYNACK回复ACK然后双方进入ESTABLISHED。connect函数实际上是在第一步调用后阻塞住一直到第三次握手完成之前进程都停在connect这行代码里。如果服务器一直不回应connect会反复重传SYN。Linux默认尝试7次net.ipv4.tcp_syn_retries6每次超时时间翻倍全部尝试完大概要用一百多秒。这段时间在你的业务日志里表现为请求一直卡在connect上不出来。这也是为什么很多高并发框架会建议使用非阻塞connect的原因——不能让一个线程为了等握手白白挂起两分钟。我在第3.4节会给出完整实现。3.3 临时端口是怎么挑出来的内核给客户端选源端口不是在1到65535之间随便挑而是严格限制在ip_local_port_range这个范围内。查看方法$ cat /proc/sys/net/ipv4/ip_local_port_range 32768 60999这个范围一共两万多个端口每次connect消耗一个断开后再释放。但问题在于TIME_WAIT状态的连接还占着临时端口直到2MSL时间过去才释放。如果你的服务是短连接高并发的模式每秒建立几百个连接很快就可能把这段临时端口区间全部占满。一旦端口耗尽connect会报一个很有迷惑性的错误Cannot assign requested address。很多新手以为是目标地址有问题实际上本地源端口已经用光了。这种情况我后面在错误排查部分详细展开。3.4 connect阻塞与非阻塞的玩法connect的默认行为是阻塞也就是进程会一直等待握手结果。这在客户端程序里通常没有问题但服务器程序里如果你想同时连接多个下游服务就必须用非阻塞connect。非阻塞connect的完整实现套路是这样int sockfd socket(AF_INET, SOCK_STREAM, 0); // 设置非阻塞 int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); int ret connect(sockfd, (struct sockaddr *)addr, sizeof(addr)); if (ret 0) { if (errno ! EINPROGRESS) { // 真正失败比如 EACCES、EPERM 等 perror(connect failed); close(sockfd); return; } // EINPROGRESS 表示连接正在进行需要等待 } // 用 poll 或 select 等待套接字可写 struct pollfd pfd {sockfd, POLLOUT, 0}; int timeout 5000; // 超时5秒 ret poll(pfd, 1, timeout); if (ret 0) { // 连接超时 printf(connect timeout\n); close(sockfd); return; } // 检查连接是否真的成功 int err 0; socklen_t len sizeof(err); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, err, len); if (err ! 0) { printf(connect failed: %d\n, err); close(sockfd); return; }注意最后一步getsockopt(SO_ERROR)这是很多人的盲区。非阻塞connect返回EINPROGRESS之后poll说可写了并不代表连接一定成功。如果对端拒绝连接比如端口没有服务在监听IO复用机制一样会触发可写事件。你必须用SO_ERROR取一下真正的错误码才能区分成功和失败。3.5 实测connect超时和拒绝我用一段Python代码演示connect的两种典型失败import socket # 场景1连接一个不存在的IP比如 192.0.2.1TEST-NET地址 s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) try: s.connect((192.0.2.1, 80)) except Exception as e: print(fconnect error: {e}) # 结果阻塞5秒后报超时因为SYN发出去没人回应 # 场景2连接本机一个未被监听的端口 s2 socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s2.connect((127.0.0.1, 9999)) except Exception as e: print(fconnect error: {e}) # 结果立即返回 Connection refused因为本机内核直接回了RST这两种失败在原理上完全不同。第一种是SYN发出去石沉大海可能是目标IP不可达、防火墙把包丢了、或者目标服务器忙到不响应第二种是目标端口根本没服务监听内核立刻回复RST所以错误是瞬时返回的。可以从返回速度初判问题类型秒拒大概率是端口没开卡住半天才是网络或防火墙问题。4. 完整链路从bind到accept再到connectbind和connect不是孤立存在的。对于TCP服务完整的生命周期是这样的服务端先socket - bind - listen - accept客户端再socket - connect之后双方才能互相读写。我把双方全流程画在脑图里但这里用代码逐步说明每一步都对应什么状态变化。4.1 服务端启动五连招服务端核心代码int listenfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 绑定所有网卡 addr.sin_port htons(8080); int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); bind(listenfd, (struct sockaddr *)addr, sizeof(addr)); listen(listenfd, 128); while (1) { int connfd accept(listenfd, NULL, NULL); // 处理这个 connfd 上的数据 }socket创建套接字后内核里这个套接字没有任何地址信息。bind之后套接字有了地址。listen之后套接字变成被动监听状态内核开始为它建立两个队列半连接队列和全连接队列。accept则是在全连接队列里取一个已经完成握手的连接返回一个新的套接字文件描述符专门用于这个独立连接的数据传输。这里有一个细节值得注意accept返回的套接字和 listen 套接字不是同一个。listen套接字一辈子只负责监听新连接accept返回的套接字才是真正跟单个客户端进行数据收发的通道。这也是很多人看代码时懵住的地方为什么accept明明写在while循环里却能同时处理那么多连接。4.2 客户端启动三连招客户端代码几乎只需要socket和connectint sockfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); inet_pton(AF_INET, 192.168.1.10, server_addr.sin_addr); connect(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr));connect成功之后客户端会同时获得本地端口和远端地址这一刻完整的五元组就确定了源IP源端口目标IP目标端口协议。 内核里这个连接的状态变成ESTABLISHED之后就可以通过sockfd直接收发数据了。4.3 listen的backlog和等待队列listen函数第二个参数backlog经常被低估。它的作用并不是限制可以接受连接的总数而是限制全连接队列的长度。当客户端发来一个SYN服务器respond REFSYNACK后连接先进入半连接队列当收到客户端的ACK、握手完成后连接再进入全连接队列。accept做的事就是从全连接队列里取出一个连接。如果全连接队列满了内核会对新的完成握手连接直接丢弃客户端表现就是卡在那个等待ACK的状态。这个问题在流量突增时特别常见查起来可以用ss -lnt看$ ss -lnt State Recv-Q Send-Q Local Address:Port LISTEN 128 511 0.0.0.0:8080Recv-Q这一列表示全连接队列当前积压的连接数。如果这个数字接近Send-Q也就是backlog说明你的服务accept不够快队列快满了。这时候堆代码层面应该增加accept的并发能力而不是傻乎乎地调大backlog。4.4 用tcpdump亲眼看一下三次握手纸上谈兵没什么说服力我建议你用tcpdump实际抓一次包。方法很简单终端A启动监听服务$ nc -l 9999终端B抓包$ sudo tcpdump -i lo port 9999 -nn终端C发起连接$ nc 127.0.0.1 9999这时候终端B会看到类似下面的输出IP 127.0.0.1.12345 127.0.0.1.9999: Flags [S], seq 123456789 IP 127.0.0.1.9999 127.0.0.1.12345: Flags [S.], seq 987654321, ack 123456790 IP 127.0.0.1.12345 127.0.0.1.9999: Flags [.], ack 987654322第一行Flags[S]是connect发出的SYN第二行[S.]是服务器的SYNACK第三行[.]是客户端回复的纯ACK。这三行就是完整的三次握手。如果只看到第一行而没有后两行说明SYN发出去没人理这就是连接超时问题的根源。5. 高频报错与排查速查表做网络编程时间长了你总会遇到各种connect和bind相关的报错。我把平时踩过的高频错误整理成速查表每条附带排查思路和实际命令。5.1 连接超时SYN石沉大海报错特征connect阻塞很久后返回ETIMEDOUT或者是Connection timed out。排查思路第一步ping目标IP确认网络是否可达。如果ping不通问题在路由或防火墙。第二步用telnet 目标IP 目标端口测试如果能通说明端口还好如果卡住说明端口被防火墙拦截或服务无响应。第三步抓包看SYN有没有发出、有没有重传。用tcpdump抓一次就能判断是包没出去还是包出去了没回来。一个典型的线上案例如下某个外网API偶尔超时业务方抓包发现SYN重传了好几次。后来排查发现目标服务器负载过高内核协议栈来不及响应新的SYN。优化方向是缩短客户端的tcp_syn_retries或调整超时时间而不是盲目增加重传次数。5.2 Connection refused对端口没人报错特征connect立即返回ECONNREFUSED提示Connection refused。这个错误其实是最友好的因为它说明网络是通的目标可达只是目标端口没有任何程序监听。内核收到SYN后查了一下本机端口表发现这个端口没有对应的套接字于是直接回一个RST包。排查命令$ ss -lntp | grep 8080 $ netstat -tlnp | grep 8080如果什么都没有说明服务没起来或者起在了别的端口上。常见原因包括配置写错端口、服务启动失败、进程被杀但端口还没释放。5.3 Permission deniedDocker API的权限大坑报错特征permission denied while trying to connect to the docker api at unix:///var/run/docker.sock这里其实不是网络问题而是文件系统权限问题。Docker CLI会通过Unix域套接字连接Docker守护进程/var/run/docker.sock这个socket文件的权限只对root和docker组开放。普通用户不在docker组里连接就被拒绝了。解决方式# 查看socket文件权限 $ ls -l /var/run/docker.sock srw-rw---- 1 root docker 0 ... # 把自己加入docker组 $ sudo usermod -aG docker $USER改完组之后要退出重进终端才能生效。这里我还想提醒一句给用户加docker组等于赋予了他近似root的控制权生产环境一定要谨慎别图方便把所有人都加进docker组。5.4 No buffer space available资源枯竭报错特征connect的时候报No buffer space available。这个错误很隐蔽它不一定代表内存不够更多时候是本地端口耗尽。每个TCP连接占一个本地端口如果短连接特别多而且都处在TIME_WAIT状态临时端口很快会被吃光。排查命令# 查看本地端口范围 $ cat /proc/sys/net/ipv4/ip_local_port_range # 查看系统当前连接统计 $ ss -s # 查看TIME_WAIT连接数 $ ss -tan state time-wait | wc -l如果time-wait数量上万基本可以确认是这个问题。处理思路开启net.ipv4.tcp_tw_reuse1允许安全复用TIME_WAIT连接。适当调整ip_local_port_range比如改成1024 65535。如果是后端服务主动大量连接下游尽量用连接池而不是每请求新建连接。5.5 临时端口耗尽的连锁故障临时端口耗尽不只在主动connect时报错它还可能导致各种连锁问题。举个例子Nginx作为反向代理后端的Java服务异常导致大量连接排队Nginx和后端之间的连接全挂在TIME_WAIT上。当短时间内的连接数超过临时端口上限Nginx就会报connect() to 127.0.0.1:8080 failed (99: Cannot assign requested address)看起来像是请求不到任何可用端口。很多同学第一反应是加大端口范围但如果连接数本身已经非常夸张加端口也只是把崩溃点推迟几秒。正确做法是从连接生命周期上做优化开启连接复用尽量让连接长期存活。排查后端是否有连接泄漏比如没关闭的客户端连接。调小TIME_WAIT的等待时间设置net.ipv4.tcp_fin_timeout30这类参数。6. 一点个人调试经验写到这里connect和bind的核心机制已经讲得差不多了。最后分享几条我个人长期踩坑换来的经验希望能帮你少走弯路。第一写网络程序先确认端口有没有被监听永远不要直接上代码。我在调试时最常用的命令就是ss -lntp一条命令看清本机所有监听端口和对应的进程。很多看起来诡异的连接问题最后的答案都只是端口写错了。第二学会用strace跟踪系统调用。遇到connect超时或者bind失败用strace -f -e tracenetwork ./your_program跑一遍每个系统调用的参数、返回值、错误码都清清楚楚比你在代码里打一百个日志都管用。第三从业务角度有两条安全建议不要把服务的监听地址写成0.0.0.0以外的具体IP除非你清楚知道自己在限制什么来源生产环境不要轻易把普通用户加入docker组。这两条都是我在实际维护中亲眼见过事故的。connect和bind是网络编程的起点也是排查一切网络故障的基石。把这两个函数吃透了后面再看epoll、看连接池、看负载均衡都会有原来如此的感觉。这里想通的每一个细节都会在日常开发中变成你的直觉帮你在别人还在对着报错日志挠头的时候一眼就看出问题出在哪一层。
返回列表