Linux Socket
1、概述
socket又称套接字,是Linux跨进程通信(IPC)方式的一种,它不仅仅可以做到同一台主机内跨进程通信,还可以做到不同主机间的跨进程通信。
1.1 socket诞生的原因
两台装有操作系统的服务器要想实现通信,第一要联网,第二通信双方要制定某种规则。我们平时最为常见的http请求也是一种通信协议,只不过它是属于应用层的。http协议将要发送的数据封装后,传到下面一层处理,这下一层就是传输层,也是我们今天要说的重点。
应用层的数据封装之后要发到下面的传输层,那么传输层就需要对外提供接口,让应用层可以调用传输层的数据,这个就是socket。传输层就是通过socket来对外提供服务的,毫不夸张的说,socket是计算机通信的基石,任何两台计算机要想实现通信,必须要有socket。传输层是在操作系统层面,socket的实现细节是操作系统(内核)已经封装好的了。
1.2 socket定义
socket 的原意是“插座”,在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。
2、Linux中的socket
Socket是Linux跨进程通信(IPC,Inter Process Communication)方式的一种。相比于其他IPC方式,Socket更牛的地方在于,它不仅仅可以做到同一台主机内跨进程通信,它还可以做到不同主机间的跨进程通信。根据通信域的不同可以划分成2种: Internet domain socket 和 Unix domain socket。
2.1 Internet domain socket
Internet domain socket用于实现不同主机上的进程间通信,大部分情况下我们所说的socket都是指internet domain socket。(下文不特殊指代的情况下,socket就是指internet domain socket。)
要做到不同主机跨进程通信,第一个要解决的问题就是怎么唯一标识一个进程。我们知道主机上每个进程都有一个唯一的pid,通过pid可以解决同一台主机上的跨进程通信进程的识别问题。但是如果2个进程不在一台主机上的话,pid是有可能重复的,所以在这个场景下不适用,那有什么其他的方式吗?我们知道通过主机IP可以唯一锁定主机,而通过端口可以定位到程序,而进程间通信我们还需要知道通信用的什么协议。这样一来“IP+端口+协议”的组合就可以唯一标识网络中一台主机上的一个进程。这也是生成socket的主要参数。
每个进程都有唯一标识之后,接下来就是通信了。通信这事一个巴掌拍不响,有发送端程序就有接收端程序,而Socket可以看成在两端进行通讯连接中的一个端点,发送端将一段信息写入发送端Socket中,发送端Socket将这段信息发送给接收端Socket,最后这段信息传送到接收端。至于信息怎么从发送端Socket到接收端Socket就是操作系统和网络栈该操心的事情,我们可以不用了解细节。如下图所示:
为了维护两端的连接,我们的Socket光有自己的唯一标识还不够,还需要对方的唯一标识,所以一个上面说的发送端和接收端Socket其实都只有一半,一个完整的Socket的组成应该是由[协议,本地地址,本地端口,远程地址,远程端口] 组成的一个5维数组。比如发送端的Socket就是 [tcp,发送端IP,发送端port,接收端IP,接收端port],那么接收端的Socket就是 [tcp,接收端IP,接收端port,发送端IP,发送端port]。
Socket根据通信协议的不同还可以分为3种:流式套接字(SOCK_STREAM),数据报套接字(SOCK_DGRAM)及原始套接字。
-
流式套接字(SOCK_STREAM):最常见的套接字,使用TCP协议,提供可靠的、面向连接的通信流。保证数据传输是正确的,并且是顺序的。应用于Telnet远程连接、WWW服务等。
-
数据报套接字(SOCK_DGRAM):使用UDP协议,提供无连接的服务,数据通过相互独立的报文进行传输,是无序的,并且不保证可靠性。使用UDP的应用程序要有自己的对数据进行确认的协议。
-
原始套接字:允许对低层协议如IP或ICMP直接访问,主要用于新的网络协议实现的测试等。原始套接字主要用于一些协议的开发,可以进行比较底层的操作。它功能强大,但是没有上面介绍的两种套接字使用方便,一般的程序也涉及不到原始套接字。
套接字工作过程如下图所示(以流式套接字为例):服务器首先启动,通过调用socket()建立一个套接字,然后调用bind()将该套接字和本地网络地址联系在一起,再调用listen()使套接字做好侦听的准备,并规定它的请求队列的长度,之后就调用accept()来接收连接。客户端在建立套接字后就可调用connect()和服务器建立连接。连接一旦建立,客户机和服务器之间就可以通过调用read()和write()来发送和接收数据。最后,待数据传送结束后,双方调用close()关闭套接字。
从TCP连接视角看待上述过程可以总结如图,可以看到TCP的三次握手代表着Socket连接建立的过程,建立完连接后就可以通过read,wirte相互传输数据,最后四次挥手断开连接删除Socket。
2.2 Unix domain socket
Unix domain socket 又叫 IPC(inter-process communication 进程间通信) socket,用于实现同一主机上的进程间通信。socket 原本是为网络通讯设计的,但后来在 socket 的框架上发展出一种 IPC 机制,就是 UNIX domain socket。虽然网络 socket 也可用于同一台主机的进程间通讯(通过 loopback 地址 127.0.0.1),但是 UNIX domain socket 用于 IPC 更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC 机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。
UNIX domain socket 是全双工的,API 接口语义丰富,相比其它 IPC 机制有明显的优越性,目前已成为使用最广泛的 IPC 机制,比如 X Window 服务器和 GUI 程序之间就是通过 UNIX domain socket 通讯的。Unix domain socket 是 POSIX 标准中的一个组件,所以不要被名字迷惑,linux 系统也是支持它的。
了解Docker的同学应该知道Docker daemon监听一个docker.sock文件,这个docker.sock文件的默认路径是/var/run/docker.sock,这个Socket就是一个Unix domain socket。在后面的实践环节会详细介绍。
3、Socket实践
要学好编程,最好的方式就是实践。接下来我们来实际用下Socket通信,并且观察Socket文件。
3.1 Internet domain socket实践
现在我们就用socket写一个server,server的功能很简单,就是监听1208端口,当收到输入ping时就返回pong,收到echo xxx就返回xxx,收到quit就关闭连接。socket-server.go的代码如下:
package main import ( "fmt" "net" "strings" ) func connHandler(c net.Conn) { if c == nil { return } buf := make([]byte, 4096) for { cnt, err := c.Read(buf) if err != nil || cnt == 0 { c.Close() break } inStr := strings.TrimSpace(string(buf[0:cnt])) inputs := strings.Split(inStr, " ") switch inputs[0] { case "ping": c.Write([]byte("pong\n")) case "echo": echoStr := strings.Join(inputs[1:], " ") + "\n" c.Write([]byte(echoStr)) case "quit": c.Close() break default: fmt.Printf("Unsupported command: %s\n", inputs[0]) } } fmt.Printf("Connection from %v closed. \n", c.RemoteAddr()) } func main() { server, err := net.Listen("tcp", ":1208") if err != nil { fmt.Printf("Fail to start server, %s\n", err) } fmt.Println("Server Started ...") for { conn, err := server.Accept() if err != nil { fmt.Printf("Fail to connect, %s\n", err) break } go connHandler(conn) } }
在一切皆文件的Unix-like系统中,进程生产的socket通过socket文件来表示,进程通过向socket文件读写内容实现消息的传递。在Linux系统中,通常socket文件在/proc/pid/fd/文件路径下。启动我们的socket-server,我们来窥探一下对应的socket文件。先启动server:
# go run socket-server.go Server Started ...
再开一个窗口,我们先查看server进程的pid,可以使用lsof或netstat命令:
# lsof -i :1208 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME socket-se 461354 root 3u IPv6 605145808 0t0 TCP *:seagull-ais (LISTEN) # netstat -ntlp|grep 1208 tcp6 0 0 :::1208 :::* LISTEN 461354/socket-serve
可以看到我们的server pid为461354,接下来我们来查看下server监听的socket:
# ls -l /proc/461354/fd 总用量 0 lrwx------ 1 root root 64 12月 27 09:02 0 -> /dev/pts/1 lrwx------ 1 root root 64 12月 27 09:02 1 -> /dev/pts/1 lrwx------ 1 root root 64 12月 27 09:02 2 -> /dev/pts/1 lrwx------ 1 root root 64 12月 27 09:02 3 -> socket:[605145808] lrwx------ 1 root root 64 12月 27 09:02 5 -> anon_inode:[eventpoll] lr-x------ 1 root root 64 12月 27 09:02 6 -> pipe:[605145809] l-wx------ 1 root root 64 12月 27 09:02 7 -> pipe:[605145809]
可以看到/proc/461354/fd/3是一个链接文件,指向socket:[605145808],这个便是server端的socket。socket-server启动经历了socket() –> bind() –> listen()3个过程,创建了这个LISTEN socket用来监听对1208端口的连接请求。
我们知道socket通信需要一对socket:server端和client端。现在我们再开一个窗口,在socket-server的同一台机器上用telnet启动一个client ,来看看client端的socket:
# telnet localhost 1208 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'.
继续查看server端口打开的文件描述符;
# lsof -i :1208 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME socket-se 461354 root 3u IPv6 605145808 0t0 TCP *:seagull-ais (LISTEN) socket-se 461354 root 4u IPv6 605186786 0t0 TCP localhost:seagull-ais->localhost:35123 (ESTABLISHED) telnet 469965 root 3u IPv6 605185964 0t0 TCP localhost:35123->localhost:seagull-ais (ESTABLISHED)
我们发现,相对于之前的结果多了2条,这3条分别是:
-
*:seagull-ais (LISTEN)是server监听socket文件名,所属进程pid是461354
-
localhost:seagull-ais->localhost:35123 (ESTABLISHED) 是server端为client端建立的新的socket,负责和client通信,所属进程pid是461354
-
localhost:35123->localhost:seagull-ais (ESTABLISHED)是client端为server端建立的新的socket,负责和server通信,所属进程pid是469965
在/proc/pid/fd/
文件路径下可以看到server和client新建的socket,这里不做赘述。从第3条结果我们可以看出,前2条socket,LISTEN socket和新建的ESTABLISHED socket都属于server进程,对于每条链接server进程都会创建一个新的socket去链接client,这条socket的源IP和源端口为server的IP和端口,目的IP和目的端口是client的IP和端口。相应的client也创建一条新的socket,该socket的源IP和源端口与目的IP和目的端口恰好与server创建的socket相反,client的端口为一个主机随机分配的高位端口。
从上面的结果我们可以回答一个问题 “服务端socket.accept后,会产生新端口吗”? 答案是不会。server的监听端口不会变,server为client创建的新的socket的端口也不会变,在本例中都是1208。这难到不会出现端口冲突吗?当然不会,我们知道socket是通过5维数组[协议,本地IP,本地端口,远程IP,远程端口] 来唯一确定的。socket: *:seagull-ais (LISTEN)和socket: localhost:seagull-ais->localhost:35123 (ESTABLISHED) 是不同的socket 。那这个LISTEN socket有什么用呢?我的理解是当收到请求连接的数据包,比如TCP的SYN请求,那么这个连接会被LISTEN socket接收,进行accept处理。如果是已经建立过连接后的客户端数据包,则将数据放入接收缓冲区。这样,当服务器端需要读取指定客户端的数据时,则可以利用ESTABLISHED套接字通过recv或者read函数到缓冲区里面去取指定的数据,这样就可以保证响应会发送到正确的客户端。
上面提到客户端主机会为发起连接的进程分配一个随机端口去创建一个socket,而server的进程则会为每个连接创建一个新的socket。因此对于客户端而言,由于端口最多只有65535个,其中还有1024个是不准用户程序用的,那么最多只能有64512个并发连接。对于服务端而言,并发连接的总量受到一个进程能够打开的文件句柄数的限制,因为socket也是文件的一种,每个socket都有一个文件描述符(FD,file descriptor),进程每创建一个socket都会打开一个文件句柄。该上限可以通过ulimt -n查看,通过增加ulimit可以增加server的并发连接上限。本例的server机器的ulimit为:
# ulimit -n 1024
上面讲了半天服务端与客户端的socket创建,现在我们来看看服务端与客户端的socket通信。还记得我们的server可以响应3个命令吗,分别是ping,echo和quit,我们来试试:
# telnet localhost 1208 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. ping pong echo Hello,socket Hello,socket quit Connection closed by foreign host.
我们可以看到client与server通过socket的通信。
到此为止,我们来总结下从telnet发起连接,到客户端发出ping,服务端响应pong,到最后客户端quit,连接断开的整个过程:
-
telnet发起向localhost:1208发起连接请求;
-
server通过socket: TCP *:seagull-ais (LISTEN)收到请求数据包,进行accept处理;
-
server返回socket信息给客户端,客户端收到server socket信息,为客户端进程分配一个随机端口35123,然后创建socket: TCP localhost:35123->localhost:seagull-ais 来连接服务端;
-
服务端进程创建一个新的socket: TCP localhost:seagull-ais->localhost:35123来连接客户端;
-
客户端发出ping,ping数据包send到socket: TCP localhost:35123->localhost:seagull-ais;
-
服务端通过socket: TCP localhost:seagull-ais->localhost:35123收到ping数据包,返回pong,pong数据包又通过原路返回到客户端 ,完成一次通信。
-
客户端进程发起quit请求,通过上述相同的socket路径到达服务端后,服务端切断连接,服务端删除socket: TCP localhost:seagull-ais->localhost:35123释放文件句柄;客户端删除 socket: TCP localhost:35123->localhost:seagull-ais,释放端口 35123。
在上述过程中,socket到socket之间还要经过操作系统,网络栈等过程,这里就不做细致描述。
3.2 Unix domain socket实践
我们知道docker使用的是client-server架构,用户通过docker client输入命令,client将命令转达给docker daemon去执行。docker daemon会监听一个unix domain socket来与其他进程通信,默认路径为/var/run/docker.sock。我们来看看这个文件:
# ls -l /var/run/docker.sock srw-rw---- 1 root docker 0 Aug 31 01:19 /var/run/docker.sock
可以看到它的Linux文件类型是“s”,也就是socket。通过这个socket,我们可以直接调用docker daemon的API进行操作,接下来我们通过docker.sock调用API来运行一个nginx容器,相当于在docker client上执行:
# docker run nginx
与在docker client上一行命令搞定不同的是,通过API的形式运行容器需要2步:创建容器和启动容器。
1. 创建nginx容器,我们使用curl命令调用docker API,通过–unix-socket /var/run/docker.sock指定Unix domain socket。首先调用/containers/create,并传入参数指定镜像为nginx,如下:
# curl -XPOST --unix-socket /var/run/docker.sock -d '{"Image":"nginx"}' -H 'Content-Type: application/json' http://localhost/containers/create {"Id":"67bfc390d58f7ba9ac808d3fc948a5d4e29395e94288a7588ec3523af6806e1a","Warnings":[]}
2. 启动容器,通过上一步创建容器返回的容器id,我们来启动这个nginx:
# curl -XPOST --unix-socket /var/run/docker.sock http://localhost/containers/67bfc390d58f7ba9ac808d3fc948a5d4e29395e94288a7588ec3523af6806e1a/start # docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 67bfc390d58f nginx "/docker-entrypoint.…" About a minute ago Up 7 seconds 80/tcp romantic_heisenberg
至此,通过Unix domain socket我们实现了客户端进程curl与服务端进程docker daemon间的通信,并成功地调用了docker API运行了一个nginx container。
值得注意的是,在连接服务端的Unix domain socket的时候,我们直接指定的是服务端的socket文件。而在使用Internet domain socket的时候,我们指定的是服务端的IP地址和端口号。
4、总结
- Socket是Linux跨进程通信方式的一种。它不仅仅可以做到同一台主机内跨进程通信,它还可以做到不同主机间的跨进程通信。根据通信域的不同可以划分成2种:Unix domain socket 和 Internet domain socket。
- Internet domain socket根据通信协议划分成3种:流式套接字(SOCK_STREAM),数据报套接字(SOCK_DGRAM)及原始套接字
- 一个完整的Socket的组成应该是由[协议,本地地址,本地端口,远程地址,远程端口]组成的一个5维数组。
参考:
- 手撕Linux Socket——Socket原理与实践分析
- socket的accept函数解析以及服务器和多个客户端的端口问题
- socket.accept后,会产生新端口吗?
- 什么是套接字(Socket)
- 使用 Go 进行 Socket 编程 | 始于珞尘
- Docker Tips : about /var/run/docker.sock