ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

IP、端口与Socket:网络编程三要素一次讲透

IP、端口与Socket:网络编程三要素一次讲透 前阵子帮人排查一个Nginx启动失败错误信息是bind() to 0.0.0.0:8080 failed: Address already in use。换作以前我大概率直接换个端口绕过去但那次追下去才发现真正的原因是旧进程没退干净端口被一个处在 TIME_WAIT 状态的连接占着。这件事让我想起很多刚入门 Linux 网络编程的朋友卡住的往往不是不会抄 socket 代码而是 IP、端口、Socket 这三个概念在脑子里没有对齐。IP 是网络层的端口是传输层的Socket 是操作系统给程序暴露的接口——它们确实各管一摊但在一起配合时最容易让人犯迷糊。这篇我打算直接掰开揉碎讲清楚这三剑客不堆术语不背书就对着实际代码、实际报错和实际排查命令来聊。不管你是刚写完第一个 C/S 程序的学生还是被线上端口问题折腾过的运维开发这篇的思路应该都能让你少走几天弯路。1. 从一次“神秘的连接失败”说起IP、端口、Socket分别干了什么先别急着上代码。我见过太多人把 socket 程序跑不起来第一反应是去改代码结果问题根本不在代码里。想搞清楚问题出在哪你得先明白一次网络通信到底经历了什么。1.1 一个连接究竟是怎么找到“那台机器、那个程序”的假设你写了一台后端服务监听在 8080 端口结果另一台机器上的客户端连不上。你会怎么排查最粗的粒度是看 IP。IP 负责把数据包送到某一台主机。如果 IP 都不通那后面全是废话——Ping 不通的时候你得先看路由、网卡、网络连通性。但 IP 通不代表连接就能建立成功。因为一台主机上跑着几十个程序Nginx、Redis、MySQL、你写的 Go 服务……数据包到了机器门口它怎么知道该进谁的房间这就是端口的任务。端口是传输层用来标识“这台主机上的哪个进程/服务”的编号。IP 管的是“找到那栋楼”端口管的是“敲哪一扇门”。如果 IP 正确但端口上没人监听客户端的 connect 会直接收到一个 Connection refused翻译过来就是“地址找到了但这个门没人开”。再往上程序自己怎么参与这件事程序不会挥舞着 TCP/IP 协议栈到处跑它只能通过操作系统提供的 Socket 接口去“创建一个通信端点”“绑定端口”“发起连接”“收发数据”。Socket 就是应用层和内核协议栈打交道的那根“接线柱”。这三个角色的关系可以粗暴地类比成寄快递IP 是“省市区街道门牌号”决定包裹到哪栋楼端口是“这栋楼里的房间号”决定包裹最后给哪一户而 Socket 是“你下单填单子的那个 App”你通过它把地址和房间号填进去剩下的运输细节交给快递系统。1.2 传输层视角下Socket就是“你手里的电话听筒”很多人第一次学 TCP 编程都会困惑socket 到底是一个什么东西是文件是描述符是一个数据结构从程序员的角度socket 在使用上就是一个文件描述符——你可以 read、write、close 它。但从传输层语义看它更像一个“电话听筒”你拨号之前得先有一部电话机socket打电话得先知道对方的号码IP:端口然后拨号connect接通后双方对着听筒说话send/recv。listen和accept这两个动词放在电话场景里就特别好懂。一个服务端程序先办了一部电话socket然后报装了一个号码bind绑定 IP 和端口接着把电话设为“来电等待”状态listen最后不停接听accept。注意 accept 每接一次操作系统都会给你一个新的“听筒”来描述这个具体通话原来的监听听筒还在继续等待下一个来电。1.3 三者的边界一张表看懂谁管哪一层概念所属层次核心职责生活类比IP 地址网络层把数据包从源主机路由到目标主机城市街道门牌号端口号传输层在目标主机上标识具体进程或服务楼栋里的房间号Socket操作系统提供的编程接口让程序创建通信端点、绑定地址、连接、收发你手里的电话听筒记住一个关键点IP 和端口不在同一个层次。IP 的取值空间和端口的取值空间互相独立你存在“这个 IP 的 8080 和那个 IP 的 8080 是两个东西”这种直觉是对的但要说清楚是因为端口必须和 IP 组合起来才构成一个完整的“传输层端点”。这也引出了下一个话题端口这枚“门牌号”本身的讲究。2. 端口不是“数字”那么简单从端口号到五元组的完整认知很多初学者以为端口就是一个 0 到 65535 的数字随便挑一个用就行。真到了实战你会发现端口背后的规矩多得是哪些要 root 权限、哪些留给系统、客户端为什么不需要 bind、一个端口到底能撑多少连接……这些不搞清楚后面踩坑基本都是从这里埋下的。2.1 六万多个端口号是怎么分段的端口号是 16 位总共 0 到 65535。IANA 分了三段0-1023Well-Known Ports。HTTP 的 80、HTTPS 的 443、SSH 的 22 都在这。在 Linux 上普通用户默认没权限 bind 到这个区间需要 root 或 CAP_NET_BIND_SERVICE 能力。这个设计有安全考量防止普通用户随便注册一个特权服务来伪装系统服务。1024-49151Registered Ports。常见应用默认端口大多在这里比如 MySQL 的 3306、Redis 的 6379、Tomcat 的 8080。49152-65535Dynamic/Private Ports。这一大段通常被内核拿来当作客户端的临时端口。第三段特别有意思。你的客户端程序明明没 bind 任何端口为什么 connect 之后还能收到服务端回包因为内核在你 connect 的时候偷偷从ip_local_port_range这个区间里挑了一个空闲端口作为客户端的源端口。这个范围用 sysctl 看cat /proc/sys/net/ipv4/ip_local_port_range # 通常输出类似32768 60999也就是说一台机器作为客户端主动往外连的时候源端口只能从这几万个里面挑。如果短时间发起大量连接源端口不够用你会看到Cannot assign requested address这种报错那又是另一类四元组资源耗尽问题。2.2 “一个端口只能被一个进程监听”这句经典误解错在哪那个说法不完全错但不严谨。准确说法是同一个 IP 加同一个端口在同一时刻只能有一个 socket 处于 LISTEN 状态。你在同一台机器上没法让两个进程都 bind0.0.0.0:8080并进入监听这就是 EADDRINUSE。但“端口被占”不等于“这个端口不能用了”。端口是二维编号而连接是四元组源 IP、源端口、目标 IP、目标端口。TCP 和 UDP 的端口空间也是互相独立的TCP 的 53 和 UDP 的 53 互不冲突——DNS 同时用两者跑就依赖这个独立性。真正重要的认知是监听端口和连接端口是两回事。一个处于 LISTEN 状态的0.0.0.0:8080可以同时承接成千上万个 ESTABLISHED 连接。因为每个连接靠“客户端的 IP 和端口”来区分服务端这边的本地 IP:端口虽然都是 8080但四元组整体是唯一的。你用ss -tn看一堆 ESTABLISHED 连接都指向本机 8080完全正常。2.3 监听端口与连接端口同一个8080能撑多少连接从理论上讲一个监听在0.0.0.0:8080的 TCP 服务最多能承载多少条连接答案受限于客户端数量和内核资源。服务端的四元组固定的是“服务端 IP:8080 客户端 IP:客户端端口”只要这两项组合不同就能建立新连接。假设客户端 IP 有 1 万个每个客户端最多约 2.8 万个可用源端口理论上存在海量组合。现实中先被耗尽的通常是文件描述符数量、内存和 CPU而不是端口号。这个认知在实际运维里特别有用。比如你看到一台机器某个端口有几十万条 ESTABLISHED 连接不要下意识说“端口被占满了”。端口占满通常是客户端那边源端口耗尽而不是服务端 8080 不够用。3. 用C写一个最小echo server三剑客的正确打开顺序概念聊完得上真东西。我用 C 写一个最简单的一对一 TCP echo server配合一个 client把 socket、bind、listen、accept、connect 这些函数挨个走一遍。你不用全背重点是理解为什么顺序必须是那样。3.1 socket() 创建的不只是一个“文件描述符”服务端第一步#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } // ... 后面继续 }socket(AF_INET, SOCK_STREAM, 0)三个参数分别表示地址族用 IPv4套接字类型用流式套接字协议用 0 让内核根据前两者自动选 TCP。这一步创建的是一个通信端点但它此刻还没有任何 IP 和端口信息。你可以理解成“买了一部裸手机还没插卡”。为什么要这样设计因为“创建一个套接字”和“给它分配地址”是两个独立操作。有的场景下程序根本不需要关心本机地址比如客户端有的场景需要绑定特定 IP比如服务器上多个网卡时只对外提供某个网段。把两步分开灵活性最大。3.2 bind() 为什么是服务端的“专利”服务端紧接着要做的是struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8080); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(listen_fd); exit(1); }bind 的作用是把这个 socket 绑定到一个具体的本地地址IP 端口。INADDR_ANY也就是0.0.0.0表示监听本机所有网卡。你要是写inet_addr(127.0.0.1)那就只监听回环地址外部机器一概连不进来。客户端通常不需要 bind这是很多新手问过我的问题。原因是客户端对源端口不在乎只要系统能给一个空闲的临时端口就行。如果你强行 bind 一个固定端口反而可能因为端口被别的客户端占用而报错。注意htons和htonl这两个函数。x86 之类的机器是小端字节序而网络传输规定用大端字节序。htons(8080)把主机字节序的 8080 转成网络字节序。如果把 8080 直接塞进sin_port在网络上对端的程序解析出来会是一个莫名其妙的大数连接自然失败。这个坑我当年栽过表现现象是“明明端口写对了connect 就是不通”实际上查一下sin_port的值才发现字节序反了。3.3 listen() 和 accept() 之间的“隐藏队列”bind 完还不能直接收数据必须先告诉内核“我这准备好接客了”if (listen(listen_fd, 128) 0) { perror(listen); close(listen_fd); exit(1); }listen的第二个参数是 backlog也就是“已完成 TCP 三次握手、但还没被 accept 取走的连接”队列长度上限。内核会自动帮你完成三次握手然后把这个新连接放进全连接队列等着你 accept。accept做的事是从队列里取出一个连接返回一个新的文件描述符。struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); while (1) { int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (conn_fd 0) { perror(accept); continue; } printf(new connection from %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); char buf[1024]; ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) { send(conn_fd, buf, n, 0); } close(conn_fd); }为什么 accept 返回的是新 fd而不是直接用 listen_fd因为 listen_fd 是用来“监听新连接”的它本身不承担具体数据传输。每来一个客户端内核就创建一个新的 socket 表示这条专属连接数据收发都走新 fd。这样监听 fd 才能持续工作继续接收后续连接。3.4 客户端connect()背后内核自动干的活客户端代码简单得多#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int client_fd socket(AF_INET, SOCK_STREAM, 0); if (client_fd 0) { perror(socket); exit(1); } struct sockaddr_in server; memset(server, 0, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(8080); inet_pton(AF_INET, 127.0.0.1, server.sin_addr); if (connect(client_fd, (struct sockaddr *)server, sizeof(server)) 0) { perror(connect); close(client_fd); exit(1); } const char *msg hello from client; send(client_fd, msg, strlen(msg), 0); char buf[1024]; ssize_t n recv(client_fd, buf, sizeof(buf), 0); if (n 0) { write(STDOUT_FILENO, buf, n); write(STDOUT_FILENO, \n, 1); } close(client_fd); return 0; }connect 背后干了三件事第一因为客户端没有 bind内核根据路由表选一个合适的本地 IP并从ip_local_port_range里挑一个空闲端口第二向服务端发 SYN执行三次握手第三握手成功后 connect 返回socket 进入 ESTABLISHED。connect 的错误码很值得记一下。连接被拒绝目标端口没人监听报ECONNREFUSED数据包发出去但没有回应常见于防火墙直接丢弃会ETIMEDOUT目标不可达会报EHOSTUNREACH。看到这几个错误基本能推断出问题在哪一段链路。4. 最容易翻车的三个socket大坑端口占用、地址重用、TIME_WAIT代码能跑通只能算第一步。真实环境里你写的东西一旦重启、换端口、部署到服务器各种奇奇怪怪的报错就来了。下面这三个坑我基本是每个都踩过而且每个都能写个小作文。4.1 第一次跑bind就报EADDRINUSE的完整排查链路场景很典型你把服务停掉改了两行代码重新启动结果 bind 直接失败日志里写着Address already in use。新手第一反应通常是“谁在占端口”于是立刻换一个端口——这是治标不治本。正确的排查链路应该是这样第一步看谁在监听这个端口ss -lntp | grep 8080如果输出里有 LISTEN 状态的进程那就是真的有人在监听。用ps看一下进程是谁再决定是 kill 还是挪端口。第二步如果ss -lntp什么都没输出但 bind 依然失败十有八九是 TIME_WAIT 状态在作祟ss -tan | grep 8080你会发现原来有一条连接的状态是TIME-WAIT。TIME_WAIT 是 TCP 主动关闭方在四次挥手最后要停留的状态时长通常是 2 * MSLLinux 上一般默认 60 秒。它的作用是保证最后一个 ACK 有机会重发同时防止旧连接上的延迟报文串到新连接里。副作用就是主动关闭方在 60 秒内同一四元组不能被复用所以如果你快速重启服务端bind 就会撞墙。这个机制经常被老运维拿来解释“端口怎么还不释放”。它不是 bug是 TCP 设计上的必要代价。4.2 SO_REUSEADDR到底解决了什么问题很多人只知道“bind 前设置 SO_REUSEADDR 可以避免 Address already in use”但不知道为什么、什么时候有效。int opt 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)) 0) { perror(setsockopt); exit(1); }注意时机必须在 bind 之前设置。SO_REUSEADDR 最核心的作用是允许监听 socket 绑定一个正处于 TIME_WAIT 状态的本地地址也就是“刚被主动关闭的端口可以立刻被重新监听”。这对服务端快速重启是救命稻草。但它不是万能的钥匙。它不能让两个监听 socket 同时绑定同一个 IP 和端口——那两个都处于 LISTEN 状态时照样 EADDRINUSE。它也不能绕过安全限制普通用户照样 bind 不了 80 端口。此外它只是允许 TIME_WAIT 期间的本地端口被重用并不缩短其他正常状态的占用。4.3 bind 0.0.0.0和bind 127.0.0.1的区别一次安全教训有一次我在自己电脑上写个 Redis 调试脚本图省事直接把 bind 写成了0.0.0.0然后出门在咖啡店连着公共 WiFi 试了试心想反正是开着玩。结果第二天发现日志里有来自一堆陌生 IP 的扫描尝试——我的端口对局域网甚至公网直接暴露了。从那以后我养成一个习惯本地调试一律先 bind127.0.0.1确认功能没问题再放开。这两个绑定的本质区别在于bind(127.0.0.1)只接受来自回环接口的连接。外部机器不管怎么访问数据包都进不来。bind(0.0.0.0)接受本机所有网卡上到达这个端口的连接包括局域网、公网、Docker 网桥。对于需要对外提供服务的程序绑0.0.0.0是必要的对于只想让本机访问的调试程序绑127.0.0.1是最便宜的防火墙。很多安全事件说到底就是开发图省事把内部服务绑在了所有网卡上。5. 用ss/nc/Python快速验证三剑客配合从最小demo到实战排查最后一部分我分享一些平时排查三剑客问题时的顺手工具和思路。内容不难但能帮你省下大把和搜索引擎搏斗的时间。5.1 怎么读懂ss输出的每一列ss是netstat的现代替代品输出信息量大且更准确。我最常用的组合ss -lntp # 查看所有监听中的 TCP 端口 ss -tan # 查看所有 TCP 连接及状态 ss -s # 打印协议统计摘要拿ss -lntp输出举例State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:((nginx,pid1234,...))Local Address:Port本地绑定的地址和端口。0.0.0.0:8080表示监听所有网卡的 8080。Peer Address:Port对端地址。0.0.0.0:*表示这是监听状态的默认占位没有固定对端。Recv-Q/Send-Q对监听 socket 来说Recv-Q 是当前已完成握手但还没被 accept 的连接数。如果这个数字一直很大说明你的 accept 循环处理不过来。再看ss -tan里一条 ESTABLISHED 连接State Recv-Q Send-Q Local Address:Port Peer Address:Port ESTAB 0 0 192.168.1.10:8080 192.168.1.20:54321这行的完整语义是本机192.168.1.10:8080和远端192.168.1.20:54321之间存在一条 TCP 连接。配合前面说的四元组你就能理解为什么同一个本地端口可以挂无数条连接了——因为 Peer 那一列各不相同。5.2 从“连不上”倒推是哪个环节出了问题我排查“连不上”的问题有固定套路按层递进先ping 目标IP。不通大概率是 IP 层的问题路由、网卡、防火墙禁 ICMP。IP 通再用nc -vz 目标IP 端口测端口连通性。nc -vz只建连不发数据非常适合快速试探。如果立刻Connection refused说明端口没监听或者监听在别的地址比如只 bind 了 127.0.0.1。如果一直超时很可能是中间防火墙丢弃了 SYN。这时在服务端抓包最直观tcpdump -i any port 8080。看有没有 SYN 进来、有没有 SYN-ACK 出去问题出在哪儿一目了然。这套流程几乎能覆盖 90% 的“socket 连不上”问题。我见过不少人一上来就怀疑代码然后反复改重试逻辑最后发现是服务端监听的 IP 不对。5.3 Python三行代码快速验证思路写 C 的 socket 程序编译麻烦改起来更麻烦。我经常先用 Python 把思路跑通再翻译回 C。服务端验证import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((0.0.0.0, 8080)) s.listen(5) print(listening on 8080) conn, addr s.accept() print(new connection from, addr) data conn.recv(1024) print(received:, data) conn.sendall(data) conn.close()客户端验证import socket c socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect((127.0.0.1, 8080)) c.sendall(bhello socket) print(c.recv(1024)) c.close()Python 的setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)和 C 里那行完全对应。你用这个快速验证端口、IP、收发逻辑都没问题后再动手写 C心态会稳很多。这里也可以顺带提测带宽和并发场景时怎么验证压测工具一上来先看ss -s里的连接总数和 TIME_WAIT 数量。如果 TIME_WAIT 飙升说明你的服务代码大概率在主动关闭连接而不是客户端在关。配合ip_local_port_range一起看就能判断客户端那边是不是源端口不够了。我个人在这些年排障里最大的体会是遇到网络问题先别急着改代码先看ss再顺着 IP、端口、Socket 这条链路一层层查。换端口、重启机子只能让你躲开症状不代表问题不存在。那个占着端口的僵尸进程早晚会在别的地方再咬你一口。
返回列表