一切皆文件

  1. 在 linux 中,一切皆为文件,所有不同种类的类型都被抽象成文件。如:普通文件、目录、字符设备、块设备、套接字等
  2. 当一个文件被进程打开,就会创建一个文件描述符。这时候,文件的路径就成为了寻址系统,文件描述符成为了字节流的接口
  3. 相对于普通文件这类真实存在于文件系统中的文件,tcp socket、unix domain socket 等这些存在于内存中的特殊文件在被进程打开的时候,也会创建文件描述符。所以 “一切皆文件” 更准确的描述应该是 “一切皆文件描述符”

文件描述符概述

  • 在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件目录文件链接文件设备文件
  • 文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件
  • 所有执行I/O操作的系统调用都通过文件描述符。
  • 程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。POSIX标准要求每次打开文件时(含socket)必须使用当前进程中最小可用的文件描述符号码,因此,在网络通信过程中稍不注意就有可能造成串话
文件描述符 用途 POSIX名称 stdio流
0 标准输入 STDIN_FILENO stdin
1 标准输出 STDONT_FILENO stdout
2 标准错误 STDERR_FILENO stderr

即:

  • 文件描述符是一个抽象索引,它指向普通的文件或者 I/O 设备
  • 文件描述符是一个非负整数,它是连接用户空间和内核空间纽带。

img

文件描述与打开的文件对应模型

img

文件描述符合打开文件之间的关系

  • 每一个文件描述符会与一个打开文件相对应,同时,不同的文件描述符也会指向同一个文件。相同的文件可以被不同的进程打开也可以在同一个进程中被多次打开。
  • 系统为每一个进程维护了一个文件描述符表,该表的值都是从0开始的,所以在不同的进程中你会看到相同的文件描述符,这种情况下相同文件描述符有可能指向同一个文件,也有可能指向不同的文件。具体情况要具体分析,要理解具体其概况如何,需要查看由内核维护的3个数据结构。
    1. 进程级的文件描述符表
    2. 系统级的打开文件描述符表
    3. 文件系统的i-node表

进程级的描述符表的每一条目记录了单个文件描述符的相关信息。

  1. 控制文件描述符操作的一组标志。(目前,此类标志仅定义了一个,即close-on-exec标志)
  2. 对打开文件句柄的引用

内核对所有打开的文件的文件维护有一个系统级的描述符表格(open file description table)。有时,也称之为打开文件表(open file table),并将表格中各条目称为打开文件句柄(open file handle)。

一个打开文件句柄存储了与一个打开文件相关的全部信息,如下所示:

  1. 当前文件偏移量(调用read()和write()时更新,或使用lseek()直接修改)

  2. 打开文件时所使用的状态标识(即,open()的flags参数)

  3. 文件访问模式(如调用open()时所设置的只读模式、只写模式或读写模式)

  4. 与信号驱动相关的设置

  5. 对该文件i-node对象的引用

  6. 文件类型(例如:常规文件、套接字或FIFO)和访问权限

  7. 一个指针,指向该文件所持有的锁列表

  8. 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳

文件描述符、打开的文件句柄以及i-node之间的关系

img

  • 图中,两个进程拥有诸多打开的文件描述符。
  • 在进程A中,文件描述符1和30都指向了同一个打开的文件句柄(标号23)。这可能是通过调用dup()、dup2()、fcntl()或者对同一个文件多次调用了open()函数而形成的。
  • 进程A的文件描述符2和进程B的文件描述符2都指向了同一个打开的文件句柄(标号73)。这种情形可能是在调用fork()后出现的(即,进程A、B是父子进程关系),或者当某进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程时,也会发生。再者是不同的进程独自去调用open函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。
  • 此外,进程A的描述符0和进程B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表的相同条目(1976),换言之,指向同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了open()调用。同一个进程两次打开同一个文件,也会发生类似情况。

从 python 感知 fd

1
2
3
4
5
6
7
8
9
10
>>> import os
>>> fd = os.open("/Users/heyingliang/myTemp/api.json",os.O_RDONLY)
>>> fd
3
>>> fd2 = os.open("/Users/heyingliang/myTemp/ones-wechat-api.log",os.O_RDONLY)
>>> fd2
4
>>> fd3 = os.open("/Users/heyingliang/myTemp/response222.png",os.O_RDONLY)
>>> fd3
5

总结

  1. 由于进程级文件描述符表的存在,不同的进程中会出现相同的文件描述符,它们可能指向同一个文件,也可能指向不同的文件
  2. 两个不同的文件描述符,若指向同一个打开文件句柄,将共享同一文件偏移量。因此,如果通过其中一个文件描述符来修改文件偏移量(由调用read()、write()或lseek()所致),那么从另一个描述符中也会观察到变化,无论这两个文件描述符是否属于不同进程,还是同一个进程,情况都是如此。
  3. 要获取和修改打开的文件标志(例如:O_APPEND、O_NONBLOCK和O_ASYNC),可执行fcntl()的F_GETFL和F_SETFL操作,其对作用域的约束与上一条颇为类似。
  4. 文件描述符标志(即,close-on-exec)为进程和文件描述符所私有。对这一标志的修改将不会影响同一进程或不同进程中的其他文件描述符

Unix domain socket 描述符

  • Unix domain socket 描述符主要用于:运行在同一台机器上的 2 个进程相互之间的数据通信
  • Unix domain socket 描述符和网络文件描述符非常相似(比如:TCP socket),他们的通信发生在操作系统内核
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# server.py
import socket

server_addr = '/tmp/server.sock'

sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(server_addr)
sock.listen(0)

while True:
conn, clientAddr = sock.accept()
while True:
data = conn.recv(100)
conn.sendall(data)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# client.py
import socket
import time

server_addr = '/tmp/server.sock'

sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(server_addr)

while True:
message = 'hello world!'
sock.sendall(message)
sock.recv(100)
time.sleep(1)

sock.close()

运行 server.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[root@localhost ~]# python /tmp/server.py &d
[1] 2554
[root@localhost ~]# ls -l /proc/2554/fd
total 0
lrwx------ 1 root root 64 Nov 5 02:39 0 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 1 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 2 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 3 -> socket:[28724]
[root@localhost ~]# grep 28724 /proc/net/unix
ffff90d8ba564000: 00000002 00000000 00010000 0001 01 28724 /tmp/server.sock
[root@localhost ~]# lsof -n | grep 28724
python 2554 root 3u unix 0xffff90d8ba564000 0t0 28724 /tmp/server.sock
[root@localhost ~]# netstat -anp | grep 28724
unix 2 [ ACC ] STREAM LISTENING 28724 2554/python /tmp/server.sock

进程 2554 创建了打开了 unix domain socket 描述符(3 -> socket:[28724]),并且通过该描述符,打开了 /tmp/server.sock 文件,其主要作用是用于监听该文件

运行 client.py

1
2
3
4
5
6
7
8
9
10
11
12
[root@localhost ~]# python /tmp/client.py &
[2] 2555
[root@localhost ~]# ls -l /proc/2555/fd
total 0
lrwx------ 1 root root 64 Nov 5 02:39 0 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 1 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 2 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 3 -> socket:[28728]
[root@localhost ~]# grep 28728 /proc/net/unix
ffff90d8b95b0400: 00000003 00000000 00000000 0001 03 28728
[root@localhost ~]# lsof -n | grep 28728
python 2555 root 3u unix 0xffff90d8b95b0400 0t0 28728 socket

client.py 创建了 unix domain socket 描述符 3 -> socket:[28728],通过 socket:[28728],找到一条 socket

server.py 发生的变化

1
2
3
4
5
6
7
[root@localhost ~]# ls -l /proc/2554/fd
total 0
lrwx------ 1 root root 64 Nov 5 02:39 0 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 1 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 2 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 5 02:39 3 -> socket:[28724]
lrwx------ 1 root root 64 Nov 5 02:39 4 -> socket:[28725]

server.py 新增了一个 4 -> socket:[28725],这是刚才 client.py 连接成功之后 server.py 新打开的描述符

1
2
3
4
5
6
7
8
[root@localhost ~]# lsof -n | grep -E '28728|28724|28725'
python 2554 root 3u unix 0xffff90d8ba564000 0t0 28724 /tmp/server.sock
python 2554 root 4u unix 0xffff90d8b95b0000 0t0 28725 /tmp/server.sock
python 2555 root 3u unix 0xffff90d8b95b0400 0t0 28728 socket
[root@localhost ~]# netstat -anp | grep unix | grep -E '28728|28724|28725'
unix 2 [ ACC ] STREAM LISTENING 28724 2554/python /tmp/server.sock
unix 3 [ ] STREAM CONNECTED 28725 2554/python /tmp/server.sock
unix 3 [ ] STREAM CONNECTED 28728 2555/python

到目前为止,整个 unix domain socket 的通信过程已经比较清晰的展现了:

  • server.py 启动之后,打开监听的描述符,等待来自客户端的连接请求
  • client.py 启动之后,与 server 连接成功,打开一个描述符用于与 server.py 通信
  • server.py 会再打开一个描述符用于与 client.py 进行数据通信

/tmp/server.sock 到底作用是什么

/tmp/server.sock 是操作系统的实体文件,拥有一个全局的文件系统描述符,这个描述符在操作系统中是唯一的。

server.py 启动时打开了 server.sock,声明了与 server.py 建立连接就只能通过 server.sock 文件。这就相当于 TCP socket 中四元组中的两元(server_ip:server_port

server 与 client 是怎么进行数据通信的

我们来使用 strace 命令看看 server.py 的内核调用:

1
2
3
4
5
6
[root@localhost tmp]# strace -p 2554
strace: Process 2554 attached
recvfrom(4, "hello world!", 100, 0, NULL, NULL) = 12
sendto(4, "hello world!", 12, 0, NULL, 0) = 12
recvfrom(4, "hello world!", 100, 0, NULL, NULL) = 12
sendto(4, "hello world!", 12, 0, NULL, 0) = 12

server.py 在接收客户端数据的时候,使用了 4 -> socket:[28725] 这个文件描述符

再看 client.py 的内核调用:

1
2
3
4
5
6
7
[root@localhost tmp]# strace -p 2555
strace: Process 2555 attached
select(0, NULL, NULL, NULL, {0, 996991}) = 0 (Timeout)
sendto(3, "hello world!", 12, 0, NULL, 0) = 12
recvfrom(3, "hello world!", 100, 0, NULL, NULL) = 12
select(0, NULL, NULL, NULL, {1, 0}) = 0 (Timeout)
sendto(3, "hello world!", 12, 0, NULL, 0) = 12

client.py 在与 server.py 通信的时候使用了 3 -> socket:[28728]

结论:

  1. server.py 与 client.py 连接建立成功之后,都会各自在自己的进程下打开 unix domain socket 描述符,该描述符来指向对应的 socket 内存空间(下面简称 s_mem
  2. client.py 通过 3 -> socket:[28728],找到 s_mem,然后写入数据 hello world!
  3. server.py 通过 4 -> socket:[28725],找到 s_mem,读取数据 hello world!,并且原封不动的发送这串数据给 client.py
  4. client.py 通过读取 s_mem,获取从 server.py 传来的数据
  5. 循环往复
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
           client.py                         server.py
+---------------+ +---------------+
|pid:2555 | |pid:2554 |
| +-----+ | | +-----+ |
| |fd:3 | | | |fd:4 | |
| +-----+ | | +-----+ |
+---------------+ +---------------+
| |
user space | |
+---------------------------------------------------------------------+
kernel space | |
| |
v v
+--------------+ +--------------+
|socket:[28728]| |socket:[28725]|
+------+-------+ +------+-------+
| |
| |
v v
+------------------------------------+
| socket |
+------------------------------------+

小结

  • /tmp/server.sock 作为建立 unix domain socket 连接的唯一标识符
  • unix domain socket 连接建立完成之后在内存开辟一块空间,而 server 与 client 在这块内存空间中进行数据传输
  • 在同一台机器上的进程通信,unix domain socket 比 tcp socket 更快,因为它不需要网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等等过程

tcp socket 描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# server.py
import socket

server_addr = ('127.0.0.1' , 22222)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(server_addr)
sock.listen(5)

while True:
conn, clientAddr = sock.accept()
while True:
data = conn.recv(100)
conn.sendall(data)

sock.close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# client.py
import socket
import time

server_addr = ('127.0.0.1' , 22222)

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(server_addr)

while True:
message = 'hello world!'
sock.send(message)
sock.recv(100)
time.sleep(1)

sock.close()

分别启动 server.py 与 client.py

1
2
3
4
[root@localhost ~]# python /tmp/server.py  &
[1] 14199
[root@localhost ~]# python /tmp/client.py &
[2] 14202

查看 server.py 打开的文件描述符

1
2
3
4
5
6
7
8
9
10
[root@localhost ~]# ls -l /proc/14199/fd
total 0
lrwx------ 1 root root 64 Nov 7 07:42 0 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 7 07:42 1 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 7 07:42 2 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 7 07:42 3 -> socket:[99154]
lrwx------ 1 root root 64 Nov 7 07:42 4 -> socket:[99155]
[root@localhost ~]# lsof -n | grep -E '99154|99155'
python 14199 root 3u IPv4 99154 0t0 TCP 127.0.0.1:22222 (LISTEN)
python 14199 root 4u IPv4 99155 0t0 TCP 127.0.0.1:22222->127.0.0.1:56946 (ESTABLISHED)

我们主要关注 ESTABLISHED 状态的 socket 描述符,也就是 4 -> socket:[99155]

1
2
3
4
[root@localhost fd]# more /proc/net/tcp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
...
4: 0100007F:56CE 0100007F:DE72 01 00000000:00000000 00:00000000 00000000 0 0 99155 1 ffff90d8bb0145c0 20 4 31 10 -1

进程打开了 tcp socket 描述符 4 -> socket:[99155],socket 描述符指向内存中的 socket 结构体,该结构体详细描述了这个 socket 的详细信息

最重要的是 TCP 四元组(local_ip:local_port --> remote_ip:remote_port),拆分转换成 10 进制

0100007F:56CE

1
2
3
4
5
6
7
[root@localhost ~]# ((d=0x01))
[root@localhost ~]# ((c=0x00))
[root@localhost ~]# ((b=0x00))
[root@localhost ~]# ((a=0x7F))
[root@localhost ~]# ((e=0x56CE))
[root@localhost ~]# echo "$a.$b.$c.$d:$e"
127.0.0.1:22222

0100007F:DE72

1
2
3
4
5
6
7
[root@localhost ~]# ((d=0x01))
[root@localhost ~]# ((c=0x00))
[root@localhost ~]# ((b=0x00))
[root@localhost ~]# ((a=0x7F))
[root@localhost ~]# ((e=0xDE72))
[root@localhost ~]# echo "$a.$b.$c.$d:$e"
127.0.0.1:56946

在 /proc/net/tcp 包含了 tcp 连接的重要状态信息:
00000000:00000000 : 发送队列与接收队列 (正数第四个字段)
-1 : 慢启动门限 (倒数第一个字段)
10 : 拥塞窗口 (倒数第二个字段)

client.py 也存在同样的行为:

1
2
3
4
5
6
7
8
9
10
11
12
[root@localhost ~]# ls -l /proc/14202/fd
total 0
lrwx------ 1 root root 64 Nov 19 04:43 0 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 19 04:43 1 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 19 04:43 2 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 19 04:43 3 -> socket:[28728]
[root@localhost ~]# lsof -n | grep 28728
python 14202 root 3u IPv4 28728 0t0 TCP 127.0.0.1:56946->127.0.0.1:22222 (ESTABLISHED)
[root@localhost fd]# more /proc/net/tcp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
...
3: 0100007F:C31A 0100007F:DE72 01 00000000:00000000 00:00000000 00000000 0 0 28728 3 ffff8a74ba1a0f80 20 4 30 10 -1

0100007F:56CE

1
2
3
4
5
6
7
[root@localhost ~]# ((d=0x01))
[root@localhost ~]# ((c=0x00))
[root@localhost ~]# ((b=0x00))
[root@localhost ~]# ((a=0x7F))
[root@localhost ~]# ((e=0x56CE))
[root@localhost ~]# echo "$a.$b.$c.$d:$e"
127.0.0.1:22222

0100007F:DE72

1
2
3
4
5
6
7
[root@localhost ~]# ((d=0x01))
[root@localhost ~]# ((c=0x00))
[root@localhost ~]# ((b=0x00))
[root@localhost ~]# ((a=0x7F))
[root@localhost ~]# ((e=0xDE72))
[root@localhost ~]# echo "$a.$b.$c.$d:$e"
127.0.0.1:56946

总结一下:

  • server.py 与 client.py 各自打开 tcp socket 描述符,该描述符指向内存中的 socket 结构体
  • socket 结构体描述了关于 TCP 的所有信息,其中通过 TCP 4 元组找到对端的通信节点
  • socket 将用户数据以及自身结构数据封装完成之后会交给底层的 TCP 协议,然后是 IP 协议、链路层信息,最后通过物理链路到达对端
  • 对端也会依次解包,直至将发送端数据写入到指定的内存当中,最终由应用程序读取(本文中的 server.py 或 client.py)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
           client.py                         server.py
+---------------+ +---------------+
|pid:14202 | |pid:14199 |
| +-----+ | | +-----+ |
| |fd:3 | | | |fd:4 | |
| +-----+ | | +-----+ |
+---------------+ +---------------+
| |
user space | |
+---------------------------------------------------------------------+
kernel space | |
| |
v v
+------+-------+ +------+-------+
|socket:[28728]| |socket:[99155]|
+------+-------+ +------+-------+
| |
| |
v v
+----+----+ +----+----+
| socket | | socket |
+----+----+ +----+----+
| |
| |
v v
++---------------------------------+-
| tcp |
+------------------------------------


小结

  • TCP 连接中最重要的是 TCP 四元组,而进程打开 TCP socket 描述符可以找到四元组信息,从而确定双方的 IP 和 port
  • 通过 socket 文件描述符可以找到内存中的 socket 结构体,获取到 TCP 连接的详细信息,包括必备四元组、文件的 inode、时间、出队入队状态等等
  • 1 个进程可以创建多个 TCP 连接,也就是创建多个 socket 文件描述符,这由该进程能够打开的文件数量限制(ulimit -n

reference