localhost 背后:一趟没有出门的网络旅行

localhost 背后:一趟没有出门的网络旅行

本博客首发在个人网站,欢迎过去评论留言!

从一个熟悉的地址说起

在终端里敲下 npm run dev,Vite 高高兴兴告诉你服务跑在 localhost:5173;浏览器一点,页面出来了。或者调 Redis 时来一句 redis-cli -h 127.0.0.1 -p 6379,连的也是本机。这个场景我们再熟悉不过了,不过最近复习 408 的计网,学习到了网络分层。

于是一个很自然、也很容易被忽略的问题冒出来了:

既然我连的是"自己",那它还算网络通信吗?是不是根本没走网络协议栈?走的话,又走了多少层?

通过简单的调查,得到的答案有点反直觉:算,而且走了不少。于是想写下这篇文章,打算自顶向下的记录下 localhost 的背后流程。

简单来说:它不像访问外网那样经过网卡、交换机、路由器和网线,但在操作系统内部,应用层、传输层、网络层这几层依然认真上班。以前以为 localhost 是"程序直接找程序聊天",但其实更像一份没有出小区的快递:没有上高速,但面单、分拣、签收流程都还在。

先说明一下:localhost 是一个名字,不是一个 IP。它通常会解析到 IPv4 的 127.0.0.1,有些系统也可能优先解析到 IPv6 的 ::1。下面为了讲清楚路径,我们默认讨论 127.0.0.1 这条 IPv4 回环路径。

前言:网络分层

在一切开始前,需要先来复习下 408 中的网络分层的知识,后面也好依据这个进行分析。

小故事环节:

网络分层如上图所示,OSI (Open System Interconnection)是国际标准化组织(ISO) 在 1977 年成立了一个专门委员会,开始研究网络互联的标准框架。他们希望制定一套通用的、开放的网络分层模型,让任何遵循该标准的系统都能互相通信。并在 1984 年正式发布的参考模型。其有着最标准的分层,不管什么网络都可以套用进来

TCP/IP 的故事要从美国国防部高级研究计划局(ARPA)说起。

  • 1969 年,ARPANET(阿帕网)诞生,这是现代互联网的前身。它的最初目的是将大学和研究机构的计算机连接起来,实现资源共享。
  • 1974 年,文特·瑟夫(Vint Cerf)和鲍勃·卡恩(Bob Kahn)发表了关于 TCP 协议的论文,首次提出了"网关"(后来演变为路由器)的概念,解决了不同网络之间的互联问题。
  • 1983 年 1 月 1 日,ARPANET 正式将其核心协议从 NCP 切换到 TCP/IP,这一天被视为现代互联网的"生日"。
  • 随后伯克利大学(BSD Unix)将 TCP/IP 的实现免费集成到 Unix 系统中,随着 Unix 在大学和科研机构的普及,TCP/IP 迅速传播开来。

由于 TCP/IP 的标准更简单高效,实现成本也更低,自然的取代了 OSI 的 方法,成为当今主流的网络分层方法。后面我们也将使用 TCP/IP 层进行具体的展开。

在继续之前,先用几句话把 TCP/IP 五层都在干什么捋明白:

  • 应用层负责"说什么",比如 HTTP、Redis、MySQL 协议。
  • 传输层负责"交给哪个进程、怎么传",TCP/UDP 都在这里,端口号也是这一层的概念。
  • 网络层负责"去哪个地址",IP 就在这里,所以 127.0.0.1 是网络层地址。
  • 链路层负责同一链路上的传输,比如以太网帧、MAC 地址这些通常在这里出现。
  • 物理层负责把比特变成电信号、光信号或无线信号,真的让数据在硬件介质里跑起来。

基于上述描述,我们可以先来分析:localhost:8080 里的 8080 是端口号,属于传输层;127.0.0.1 是 IP 地址,属于网络层。很多网络问题一旦把这两个概念分清,脑子里就不会糊成一团。

TLDR:

层级 做了什么 关键点
应用层 浏览器或客户端发起 HTTP 请求,目标地址为127.0.0.1:8080 这是数据的起点
传输层 操作系统根据端口号8080 找到对应的服务进程(比如你的 Web 服务器),并封装 TCP 段 端口号在这一层起作用
网络层 看到目标 IP 是127.0.0.1,操作系统识别出这是一个回环地址,不走物理网卡,直接交给回环接口(loopback interface)处理 这是最关键的一步------数据压根没离开本机
链路层 回环接口模拟了一个虚拟的链路层,把数据"假装"从网络发回来,实际上只是在内存里打了个转 没有真实的 MAC 地址交互
物理层 跳过。因为没有真实硬件参与,数据不会变成电信号或光信号 这也是 localhost 比外网快得多的根本原因

所以最后的 localhost,其实走完了大部分的流程,只是没有走物理层。

应用层 ------ 不经过 DNS 解析

我们先从最上层开始,看看 curl localhost:6370 的第一步做了什么。

实验对比:外网 vs localhost

我在终端中分别执行了两条命令,访问外网域名和自己的本地服务:

Shell 复制代码
curl -v http://markxu.icu
curl -v localhost:6370

并通过 tcpdump 进行抓包

Shell 复制代码
sudo tcpdump -i any port 53

可以看到在运行访问外网域名的时候,终端有相关字样:

Shell 复制代码
12:26:00.381970 IP 198.18.0.1.56714 > public1.114dns.com.domain: 53233+ A? markxu.icu. (28)
12:26:00.382370 IP public1.114dns.com.domain > 198.18.0.1.56714: 53233* 1/0/0 A 198.18.0.172 (44)
12:26:00.387550 IP 192.168.20.80.49570 > pdns.dnspod.cn.domain: 13415+ AAAA? markxu.icu. (28)
12:26:00.387656 IP 192.168.20.80.63675 > public1.alidns.com.domain: 13415+ AAAA? markxu.icu. (28)
12:26:00.387763 IP 192.168.20.80.52301 > public1.alidns.com.domain: 41468+ A? markxu.icu. (28)
12:26:00.388014 IP 192.168.20.80.56763 > pdns.dnspod.cn.domain: 41468+ A? markxu.icu. (28)

而在运行访问本地服务的时候什么都没有出现,这是因为 localhost 不是一个普通域名。根据 IETF 标准(RFC 6761),它被保留为特殊主机名。操作系统在处理 localhost 时,不会发起 DNS 查询,而是直接从 /etc/hosts 文件中读取映射关系

你可以自己验证这一点:

Shell 复制代码
cat /etc/hosts | grep localhost

应用层还做了什么

解析完成后,curl 拿到了目标地址 127.0.0.1:6370。接下来它构造 HTTP 请求报文:

Shell 复制代码
GET / HTTP/1.1
Host: localhost:6370
User-Agent: curl/8.7.1
Accept: */*

这段报文和访问外网时构造的报文在格式上没有任何区别。Host 头写的是 localhost:6370,但这只是内容不同,协议行为完全一致。

构造完成后,curl 将这个报文作为数据载荷,写入与 127.0.0.1:6370 关联的 TCP 套接字。至此,应用层的工作结束,数据交给下一层处理。

传输层 ------ TCP 仍然会握手

应用层把 HTTP 报文写进 TCP 套接字后,接力棒交给了传输层。这一步与我一开始想的有所不同:

本机通信也需要 TCP 三次握手。

TCP 是一个面向连接的协议,无论通信双方是两台远程机器,还是同一台机器的两个进程,它都会完整执行三次握手:客户端发 SYN,服务端回 SYN-ACK,客户端再回 ACK。连接建立后,才开始传输 HTTP 报文数据。

唯一的区别在于:这个握手过程不会经过任何物理线路,而是在操作系统内核内部,通过回环接口 lo瞬间完成。RTT 趋近于零,不会触发重传,也不会经历拥塞控制------但协议状态机、序列号、确认号、端口号,一个都没少。

我们可以用 tcpdump来验证这一点。打开一个终端,监听回环接口上的 6370 端口:

Shell 复制代码
sudo tcpdump -i lo0 port 6370

然后在去运行下 curl localhost 的命令,可以得到下面的恢复:

Shell 复制代码
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on lo0, link-type NULL (BSD loopback), snapshot length 524288 bytes
14:17:36.505024 IP6 localhost.62464 > localhost.6370: Flags [S], seq 3529818479, win 65535, options [mss 16324,nop,wscale 6,nop,nop,TS val 2294771787 ecr 0,sackOK,eol], length 0
14:17:36.505080 IP6 localhost.6370 > localhost.62464: Flags [R.], seq 0, ack 3529818480, win 0, length 0
14:17:36.505206 IP localhost.62465 > localhost.6370: Flags [S], seq 1917119642, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 3300697293 ecr 0,sackOK,eol], length 0
14:17:36.505344 IP localhost.6370 > localhost.62465: Flags [S.], seq 4137660183, ack 1917119643, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 4162405884 ecr 3300697293,sackOK,eol], length 0
14:17:36.505365 IP localhost.62465 > localhost.6370: Flags [.], ack 1, win 6380, options [nop,nop,TS val 3300697293 ecr 4162405884], length 0
14:17:36.505379 IP localhost.6370 > localhost.62465: Flags [.], ack 1, win 6380, options [nop,nop,TS val 4162405884 ecr 3300697293], length 0
14:17:36.505607 IP localhost.62465 > localhost.6370: Flags [P.], seq 1:78, ack 1, win 6380, options [nop,nop,TS val 3300697293 ecr 4162405884], length 77
14:17:36.505631 IP localhost.6370 > localhost.62465: Flags [.], ack 78, win 6379, options [nop,nop,TS val 4162405884 ecr 3300697293], length 0
14:17:36.514104 IP localhost.6370 > localhost.62465: Flags [P.], seq 1:1448, ack 78, win 6379, options [nop,nop,TS val 4162405893 ecr 3300697293], length 1447
14:17:36.514168 IP localhost.62465 > localhost.6370: Flags [.], ack 1448, win 6358, options [nop,nop,TS val 3300697302 ecr 4162405893], length 0
14:17:36.514408 IP localhost.62465 > localhost.6370: Flags [F.], seq 78, ack 1448, win 6358, options [nop,nop,TS val 3300697302 ecr 4162405893], length 0
14:17:36.514434 IP localhost.6370 > localhost.62465: Flags [.], ack 79, win 6379, options [nop,nop,TS val 4162405893 ecr 3300697302], length 0
14:17:36.514744 IP localhost.6370 > localhost.62465: Flags [F.], seq 1448, ack 79, win 6379, options [nop,nop,TS val 4162405894 ecr 3300697302], length 0
14:17:36.514791 IP localhost.62465 > localhost.6370: Flags [.], ack 1449, win 6358, options [nop,nop,TS val 3300697303 ecr 4162405894], length 0
^C
14 packets captured
24 packets received by filter
0 packets dropped by kernel

我们可以逐行来插接下这个信息里面包含了什么。

请先把上面的代码块调整为横向滚动模式,方便对于行号

第二行:IPv6 尝试(被拒绝)

Shell 复制代码
14:17:36.505024 IP6 localhost.62464 > localhost.6370: Flags [S], seq 3529818479, win 65535, options [mss 16324,nop,wscale 6,nop,nop,TS val 2294771787 ecr 0,sackOK,eol], length 0
14:17:36.505080 IP6 localhost.6370 > localhost.62464: Flags [R.], seq 0, ack 3529818480, win 0, length 0

因为服务端(端口 6370)没有监听 IPv6,直接回了 [R.],这说明我的服务只绑定了 127.0.0.1(IPv4),没有绑定 ::1(IPv6)

第三行:TCP 三次握手(IPv4)

Shell 复制代码
14:17:36.505206 IP localhost.62465 > localhost.6370: Flags [S], seq 1917119642, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 3300697293 ecr 0,sackOK,eol], length 0
14:17:36.505344 IP localhost.6370 > localhost.62465: Flags [S.], seq 4137660183, ack 1917119643, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 4162405884 ecr 3300697293,sackOK,eol], length 0
14:17:36.505365 IP localhost.62465 > localhost.6370: Flags [.], ack 1, win 6380, options [nop,nop,TS val 3300697293 ecr 4162405884], length 0
  • curl 改用 IPv4(127.0.0.1),源端口变为 62465
  • [S]= SYN(客户端发起连接)
  • [S.]= SYN-ACK(服务端同意连接)
  • [.]= ACK(客户端确认) 注意时间戳:三行都是 14:17:36.505,同一毫秒内完成

中间是 HTTP 的请求和相应

第六行:TCP 的四次挥手

Shell 复制代码
14:17:36.514408 IP localhost.62465 > localhost.6370: Flags [F.], seq 78, ack 1448, win 6358, options [nop,nop,TS val 3300697302 ecr 4162405893], length 0
14:17:36.514434 IP localhost.6370 > localhost.62465: Flags [.], ack 79, win 6379, options [nop,nop,TS val 4162405893 ecr 3300697302], length 0
14:17:36.514744 IP localhost.6370 > localhost.62465: Flags [F.], seq 1448, ack 79, win 6379, options [nop,nop,TS val 4162405894 ecr 3300697302], length 0
14:17:36.514791 IP localhost.62465 > localhost.6370: Flags [.], ack 1449, win 6358, options [nop,nop,TS val 3300697303 ecr 4162405894], length 0

可以看到时间戳,速度也很快。

网络层 ------ 路由让它留在本机

应用层把 HTTP 报文交给 TCP 套接字,传输层封装好 TCP 段后,数据进入网络层。这里要做两件事:封装 IP 首部,以及查路由表决定"这个包往哪送"。 我们可以通过

Shell 复制代码
route -n get 127.0.0.1

的方式,去查看我们的 localhost 在网络层是什么样的

  • 127.0.0.1的路由目的地就是它自己,接口是 lo0,flags 包含 LOCAL
  • 外网 IP 的路由目的地是对应的网段,这里的接口不是物理网卡 en0,而是 utun1024,是因为我开启了代理服务,flags 包含 GATEWAY

同时注意到的一点,可以看到我们的 MTU(Maximum Transmission Unit,最大传输单元)在本地上的上限是 macos 设定的16384,而在连接外网的上面是标准的以太网的上限 1500。

链路层 ------ 虚拟的"假装传输"

网络层做完路由决策后,数据包被标记为"发往本地"。按照正常的网络流程,下一步应该是链路层的工作:封装以太网帧、填充目标 MAC 地址,然后交给物理层发送。

但对于 127.0.0.1,事情在这里变得特殊了。

没有真正的 MAC 地址交互

我们可以用 arp命令来验证这一点:

Shell 复制代码
arp -a | grep 127.0.0.1

你会发现没有任何输出。因为对于回环地址,操作系统根本不会发起 ARP 请求来询问"谁的 IP 是 127.0.0.1?把你的 MAC 地址告诉我"。 相比之下,如果你查局域网内的其他设备:

Shell 复制代码
arp -a | grep 192.168

回环接口在做什么

操作系统内部有一个特殊的虚拟网络接口,叫做 loopback interface(在 macOS/Linux 上通常是 lo0)。 你可以用以下命令看到它的存在:

Shell 复制代码
ifconfig lo0

关键信息:

  • LOOPBACK:标明这是一个回环接口
  • inet 127.0.0.1:它绑定了这个 IP
  • mtu 16384:最大传输单元比以太网的 1500 大得多

网络层决定把数据包发给 127.0.0.1时,它实际上做的事情是:把数据包交给 lo0这个虚拟接口,lo0不会去找网卡驱动,而是直接把数据包"镜像"一份,放回操作系统的网络协议栈的接收队列。从接收端的角度看,就像是从网络上收到了一个数据包。

这个过程在操作系统内部被称为 loopback(回环),本质上是一次内存拷贝,没有任何硬件参与。

链路层还存在吗?

严格来说,链路层的职责是"在同一链路上传输数据帧"。对于回环接口,操作系统模拟了一个极简的链路层:它会封装一个假的链路层头部(在 macOS 上是 NULL类型,Linux 上是 LOCALBACK),但它不会添加真实的 MAC 地址,也不会进行 CSMA/CD(载波侦听多点接入/碰撞检测),所以也不会有任何实际的帧传输延迟。

物理层呢?

直接跳过了,不用写这部分真是太好了(^∇^)

所以答案是:localhost 走了完整的 TCP/IP 五层中的四层,唯独跳过了物理层。

它不是"程序直接找程序聊天"这种简单的概念,而是依然经历了协议封装、端口寻址、路由决策、接口调度这一整套流程。只不过所有这些都发生在一台机器内部,像是一份永远不出小区门的快递:面单照贴、分拣照做、签收照办,只是快递员从来没上过马路。

相关推荐
-To be number.wan17 天前
计算机组组成原理 | AT&T格式 和 Intel格式
学习·计算机组成原理
轻刀快马18 天前
跨越软硬件的共鸣(二):从 Cache 写策略看 Redis 与 DB 的一致性博弈
java·开发语言·redis·计算机组成原理
雪度娃娃18 天前
IO设备——总线系统
计算机组成原理
anew___20 天前
计算机组成原理:深入理解运算方法与运算器设计
计算机组成原理·运算器
-To be number.wan22 天前
计算机组成原理 | 指令格式全解析
学习·计算机组成原理
-To be number.wan22 天前
计算机组成原理 | 指令寻址
学习·计算机组成原理
悲伤小伞23 天前
计算机组成原理-概述-题
计算机组成原理
雪度娃娃23 天前
I/O设备——I/O系统总览
计算机组成原理
-To be number.wan23 天前
计算机组成原理 | 虚拟存储器
学习·计算机组成原理