socket API
socket 的主要API都定义在sys/socket.h头文件中
现代PC大多采用小端字节序,因此小端字节序又被称为主机字节序。
小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处。
发送端总是把要发送的数据转化成大端字节序数据后再发送,因此大端字节序也称为网络字节序,它给所有接收数据的主机提供了一个正确解释收到的格式化数据的保证。(JAVA虚拟机采用大端字节序)
为了保证网络字节序一致,POSIX 标准提供了如下的转换函数:
1 | uint16_t htons (uint16_t hostshort) |
- AF_LOCAL:表示的是本地地址,对应的是 Unix 套接字,这种情况一般用于本地 socket 通信,很多情况下也可以写成 AF_UNIX、AF_FILE;
- AF_INET:因特网使用的 IPv4 地址;
- AF_INET6:因特网使用的 IPv6 地址。
TCP/IP协议族有sockaddr_in和sockaddr_in6两个专用socket地址结构体,它们分别用于IPv4和IPv6:
1 | /* IPV4 套接字地址,32bit 值. */ |
所有专用socket地址(以及sockaddr_storage)类型的变量在实际使用时都需要转化为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr。
IP地址转换函数
我们一般用点分十进制字符串表示IPv4地址,以及用十六进制字符串表示IPv6地址。但编程中我们需要先把它们转化为整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的IP地址转化为可读的字符串。
1 |
|
- inet_pton函数将用字符串表示的IP地址src(用点分十进制字符串表示的IPv4地址或用十六进制字符串表示的IPv6地址)转换成用网络字节序整数表示的IP地址,并把转换结果存储于dst指向的内存中。其中,af参数指定地址族,可以是AF_INET或者AF_INET6。inet_pton成功时返回1,失败则返回0并设置errno。
- inet_ntop函数进行相反的转换,前三个参数的含义与inet_pton的参数相同,最后一个参数cnt指定目标存储单元的大小。inet_ntop成功时返回目标存储单元的地址,失败则返回NULL并设置errno。
TCP三次握手:怎么使用套接字格式建立连接?
创建 socket
UNIX/Linux的一个哲学是:所有东西都是文件。socket也不例外,它就是可读、可写、可控制、可关闭的文件描述符。下面的socket系统调用可创建一个socket:
1 |
|
domain 就是指 PF_INET、PF_INET6 以及 PF_LOCAL 等,表示什么样的套接字。
type 可用的值是:
SOCK_STREAM: 表示的是字节流,对应 TCP;SOCK_DGRAM: 表示的是数据报,对应 UDP;
-SOCK_RAW: 表示的是原始套接字。
protocol 目前一般写成 0 即可。
socket系统调用成功时返回一个socket文件描述符,失败则返回-1并设置errno。
命名 socket
将一个socket与socket地址绑定称为给socket命名。
命名socket的系统调用是bind,其定义如下:
1 |
|
bind将my_addr所指的socket地址分配给未命名的sockfd文件描述符,addrlen参数指出该socket地址的长度。
初始化例子;
1 |
|
监听 socket
socket被命名之后,还不能马上接受客户连接,我们需要使用如下系统调用来创建一个监听队列以存放待处理的客户连接:
1 |
|
sockfd参数指定被监听的socket。backlog参数提示内核监听队列的最大长度。
接受连接
当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。
1 |
|
sockfd参数是执行过 listen 系统调用的监听 socket 。addr参数用来获取被接受连接的远端 socket 地址,该 socket 地址的长度由addrlen参数指出。 accept 成功时返回一个新的连接 socket,该 socket 唯一地标识了被接受的这个连接,服务器可通过读写该 socket 来与被接受连接对应的客户端通信。accept失败时返回-1并设置errno。
accept只是从监听队列中取出连接,而不论连接处于何种状态(如
ESTABLISHED状态和CLOSE_WAIT状态),更不关心任何网络状况的变化。
监听socket字一直都存在,它是要为成千上万的客户来服务的,直到这个监听socket关闭;而一旦一个客户和服务器连接成功,完成了 TCP 三次握手,操作系统内核就为这个客户生成一个已连接socket,让应用服务器使用这个已连接socket和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是已连接socket,这样就完成了 TCP 连接的释放。请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听socket一直都处于“监听”状态,等待新的客户请求到达并服务。
客户端发起连接 connect
客户端和服务器端的连接建立,是通过 connect 函数完成的。这是 connect 的构建函数:
1 |
|
sockfd参数由 socket 系统调用返回一个 socket。serv_addr参数是服务器监听的 socket 地址,addrlen参数则指定这个地址的长度。
connect成功时返回0。一旦成功建立连接,sockfd就唯一地标识了这个连接,客户端就可以通过读写sockfd来与服务器通信。connect失败则返回-1并设置errno。其中两种常见的errno是ECONNREFUSED和ETIMEDOUT。
如果不开启服务端,TCP 客户端的 connect 函数会直接返回“Connection refused”报错信息。

服务器端通过 socket,bind 和 listen 完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待客户端的连接来临;客户端通过调用 socket 和 connect 函数之后,也会阻塞。
- 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端进入 SYNC_SENT 状态;
- 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
- 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1;
- 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
发送数据
发送数据时常用的有三个函数,分别是 write、send 和 sendmsg。
1 | ssize_t write (int socketfd, const void *buffer, size_t size) |
- 第一个函数是常见的文件写函数,如果把 socketfd 换成文件描述符,就是普通的文件写入。
- 如果想指定选项,发送带外数据,就需要使用第二个带 flag 的函数。
- 如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体 msghdr 的方式发送数据。
发送缓冲区:
发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用 write 函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
内核会按照 TCP/IP 的语义,将缓冲区里的数据封装成 TCP 的 MSS 包,以及 IP 的 MTU 包,最后走数据链路层将数据发送出去。
当应用程序的数据完全放置到发送缓冲区里时,write 阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。
发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。
读取数据
recv 函数:
1 | ssize_t recv(int sockfd,void*buf,size_t size,int flags); |
recv 读取 sockfd 上的数据,buf 和 size 参数分别指定读缓冲区的位置和大小。recv成功时返回实际读取到的数据的长度,它可能小于我们期望的长度size。因此我们可能要多次调用recv,才能读取到完整的数据。
recv可能返回0,这意味着通信对方已经关闭连接了。recv出错时返回-1并设置errno。
每次调用 recv 函数都是一次系统调用,需要从用户空间切换到内核空间,上下文切换的开销对于高性能来说最好是能省则省。
read 函数:
1 | ssize_t read (int socketfd, void *buffer, size_t size) |
read 函数要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。
关闭连接
关闭一个连接实际上就是关闭该连接对应的 socket,这可以通过如下关闭普通文件描述符的系统调用来完成:
1 |
|
fd参数是待关闭的socket。不过,close系统调用并非总是立即关闭一个连接,而是将fd的引用计数减1。只有当fd的引用计数为0时,会对套接字进行彻底释放,并且会关闭 TCP 两个方向的数据流。多进程程序中,一次fork系统调用默认将使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭。
如果无论如何都要立即终止连接(而不是将socket的引用计数减1),可以使用如下的shutdown系统调用(相对于close来说,它是专门为网络编程设计的):
1 |
|
howto 是这个函数的设置选项,它的设置有三个主要选项:
- SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回 EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
- SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为”半关闭“的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。
- SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。
close 和 shutdown 的差别:
第一个差别:close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源。
第二个差别:close 存在引用计数的概念,并不一定导致该套接字不可用;shutdown 则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
第三个差别:close 的引用计数导致不一定会发出 FIN 结束报文,而 shutdown 则总是会发出 FIN 结束报文,这在我们打算关闭连接通知对端的时候,是非常重要的
C/S模型
服务器启动后,首先创建一个(或多个)监听socket,并调用bind函数将其绑定到服务器感兴趣的端口上,然后调用listen函数等待客户连接。
服务器稳定运行之后,客户端就可以调用connect函数向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种I/O模型来监听这一事件。
I/O模型有多种,图中,服务器使用的是I/O复用技术之一的select系统调用。当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他服务器给客户端分配的逻辑单元是由fork系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端。客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。
如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。需要注意的是,服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器了(必须先处理完前一个客户的请求,才能继续处理下一个客户请求)。图中,服务器同时监听多个客户请求是通过select系统调用实现的。
TCP服务器和TCP客户端的工作流程:

UDP 编程
UDP 是一种“数据报”协议,而 TCP 是一种面向连接的“数据流”协议。
下面这张图是 UDP 程序设计时的主要过程。
- 服务器端创建 UDP 套接字之后,绑定到本地端口,调用 recvfrom 函数等待客户端的报文发送;
- 客户端创建套接字之后,调用 sendto 函数往目标地址和端口发送 UDP 报文,然后客户端和服务器端进入互相应答过程。
recvfrom 和 sendto 是 UDP 用来接收和发送报文的两个主要函数:
1 |
|
recvfrom读取本地创建的套接字sockfd上的数据,buff和len参数分别指定读缓冲区的位置和大小。因为 UDP 通信没有连接的概念,所以我们每次读取数据都需要获取发送端的 socket 地址,即参数src_addr所指的内容,addrlen参数则指定该地址的长度。返回值是实际接收的字节数。
sendto往sockfd上写入数据,buff和len参数分别指定写缓冲区的位置和大小。dest_addr参数指定接收端的 socket 地址,addrlen参数则指定该地址的长度。返回值是实际发送的字节数。
TCP 断联之后必须重新连接才可以发送报文信息。但是 UDP 报文的”无连接“的特点,可以在 UDP 服务器重启之后,继续进行报文的发送,这就是 UDP 报文“无上下文”的最好说明。
UDP的连接
我们可以对 UDP 套接字调用 connect 函数,将 UDP 套接字和 IPv4 地址进行“绑定”。但是和 TCP connect 调用引起 TCP 三次握手,建立 TCP 有效连接不同,UDP connect 函数的调用,并不会引起和服务器目标端的网络交互。
- UDP 无 connect 时,在服务器端不开启的情况下,客户端程序是不会报错的,程序只会阻塞在 recvfrom 上,等待返回(或者超时)。
- 通过对 UDP 套接字进行 connect 操作,将 UDP 套接字建立了”上下文“,该套接字和服务器端的地址和端口产生了联系,正是这种绑定关系给了操作系统内核必要的信息,能够将操作系统内核收到的信息和对应的套接字进行关联。
当调用 sendto 或者 send 操作函数时,应用程序报文被发送,我们的应用程序返回,操作系统内核接管了该报文,之后操作系统开始尝试往对应的地址和端口发送,因为对应的地址和端口不可达,一个 ICMP 报文会返回给操作系统内核,该 ICMP 报文含有目的地址和端口等信息。
操作系统内核可以从映射表中找出是哪个 UDP 套接字拥有该目的地址和端口,以快速返回异步错误的信息。
连接套接字是需要一定开销的,比如需要查找路由表信息。所以,UDP 客户端程序通过 connect 可以获得一定的性能提升。
本地套接字
本地套接字一般也叫做 UNIX 域套接字,最新的规范已经改叫本地套接字。本地套接字是本地进程间通信(IPC)的一种实现方式。利用本地套接字可以完成可靠字节流和数据报两种协议。
本地套接字是一种特殊类型的套接字,和 TCP/UDP 套接字不同。TCP/UDP 即使在本地地址通信,也要走系统网络协议栈,而本地套接字,严格意义上说提供了一种单主机跨进程间调用的手段,减少了协议栈实现的复杂度,效率比 TCP/UDP 套接字都要高许多。类似的 IPC 机制还有 UNIX 管道、共享内存和 RPC 调用等。
UNIX本地域协议族使用如下专用socket地址结构体:
1 |
|
用 netstat 查看本地 socket 连接状况:
1 | sudo netstat -a -p -A unix |
- 本地套接字本质还是进程间通信,只是借助了套接字的编程语义,比如stream和datagram,最下面肯定不走IP协议的。
- 本地套接字的编程接口和 IPv4、IPv6 套接字编程接口是一致的,可以支持字节流和数据报两种协议。
- 本地套接字的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报套接字实现。