基于socket的网络应用概述
以一个C/S结构的网络应用为例,客户端和服务器端使用socket通信的流程图如下:
从Linux内核的角度来看,一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的文件。(注:Linux中有普通文件、目录、套接字三种,Linux中一切皆是文件)
这里的read和write就很好的表现了Linux一切皆为文件的思想,进行网络通信,其实也就是对socket这个文件进行通用的Unix I/O操作。
socket通信的过程可以理解为打电话:socket为电话机。
- 建立连接(三次握手)
connect是拨号;服务器端的 bind 就好比是去电信公司开户,将电话号码和我们家里的电话机绑定,这样别人就可以用这个号码找到你; listen 是人们在家里听到了响铃;accept 是被叫的一方拿起电话开始应答。(自此,三次握手就完成了,连接建立完毕。)
- 交流(数据传输过程)
write就是给对方说话,read就是听对方说了什么。
- 关闭连接(四次挥手)
最后,拨打电话的人完成了此次交流,挂上电话,对应的操作可以理解为 close,接听电话 的人知道对方已挂机,也挂上电话,也是一次 close。
下面是偏教科书式的理解:
- 三次握手
服务器端通过 socket,bind 和 listen 完成了被动套接字的准备 工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待客户端的连接来临;客户端通过调用 socket 和 connect 函数之后,也会阻塞。接下来的事情是 由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。
下面是具体的过程:
(a) 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端 进入 SYNC_SENT 状态;
(b)服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示 对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
(c)客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务
器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对
服务器端的 SYN 包进行应答,应答数据为 k+1;
(d)应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器
端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
- 数据传输过程
客户端进程向操作系统内核发起 write 字节流写操作,内核协议栈将字节流通过 网络设备传输到服务器端,服务器端从内核得到信息,将字节流从内核读入到进程中,并开 始业务逻辑的处理,完成之后,服务器端再将得到的结果以同样的方式写给客户端。可以看 到,一旦连接建立,数据的传输就不再是单向的,而是双向的,这也是 TCP 的一个显著特 性。
- 四次挥手
和服务器端断开连接时,会执行 close 函数,操作系统内核此时会通过原先的连接链路向服务器端发送一个 FIN 包,服务器收到之后执行被动关闭,这时候整个链路处于半关闭状态,此后,服务器端也会执行 close 函数,整个链路才会真正关闭。半关闭的状态 下,发起 close 请求的一方在没有收到对方 FIN 包之前都认为连接是正常的;而在全关闭的状态下,双方都感知连接已经关闭。
Linux中与socket相关api
socket()-创建socket
要创建一个可用的套接字,需要使用下面的函数:
#include <sys/types.h>
#include <sys/socket.h>
//成功返回文件描述符,失败返回-1
int socket(int domain, int type,int protocol);
domain 就是指 PF_INET、PF_INET6 以及 PF_LOCAL 等,表示什么样的套接字。
type 可用的值是:
- SOCK_STREAM: 表示的是字节流,对应 TCP;
- SOCK_DGRAM: 表示的是数据报,对应 UDP;
- SOCK_RAW: 表示的是原始套接字。
- 自Linux2.6.17版本之后,可以为SOCK_NONBLOCK(非阻塞)和SOCK_CLOEXEC(用fork调用创建子进程时在子进程中关闭该socket)。
参数 protocol 原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol 目前一般写成 0 即可。
connect()-发起连接
客户端和服务器端的连接建立,是通过 connect 函数完成的。
#include <sys/types.h>
#include <sys/socket.h>
//成功返回0,失败返回-1;
int connect(int sockfd, const struct sockaddr* serv_addr,socklen_t addrlen);
函数的第一个参数 sockfd 是连接套接字。
第二个、第三个参数 servaddr 和 addrlen 分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的 IP 地址和端口号。
客户在调用函数 connect 前不必非得调用 bind 函数,因为如果需要的话,内核会确定源 IP 地址,并按照一定的算法选择一个临时端口作为源端口。
connect调用成功返回0。一旦成功建立连接,sockfd就唯一地标识了这个连接,客户端就可以通过读写sockfd来与服务器通信。connect失败则返回-1并设置errno,常见的两种是ECONNREFUSED和ETIMEDOUT:
- ECONNREFUSED:目标端口不存在,连接被拒绝。
- ETIMEDOUT:连接超时。
bind()-命名socket
创建socket时,指定了地址族,但是并未指定使用该地址族中的那个具体socket地址。将一个socket与socket地址绑定称为给socket命名。在服务器程序中,通常要命名socket。
#include <sys/types.h>
#include <sys/socket.h>
//成功返回0,失败返回-1
int bind(int sockfd, const struct sockaddr* myaddr,socklen_t addrlen);
bind 函数后面的第二个参数是通用地址格式sockaddr* addr
。这里虽然接收的是通用地址格式,实际上传入的参数可能是 IPv4、IPv6 或者本地套接字格式。bind 函数会根据 len 字段判断传入的参数 addr 该怎么解析,len 字段表示的就是传入的地址长度,它是一个可变值。
对于使用者来说,每次需要将 IPv4、IPv6 或者本地套接字格式转化为通用套接字格式,就像下面的 IPv4 套接字地址格式的例子一样:
struct sockaddr_in name;
bind (sock, (struct sockaddr *) &name, sizeof (name)
将地址设置成本机的 IP 地址,相当告诉操作系统内核,仅仅对目标 IP 是本机 IP 地址的 IP 包进行处理。
但是这样写的程序在部署时有一个问题,如果不清楚自己的应用程序将会被部署到哪台机器上,可以利用通配地址的能力帮助我们解决这个问题。
对于 IPv4 的地址来说,使用 INADDR_ANY 来完成通配地址的设置;对于 IPv6 的地址来说,使用 IN6ADDR_ANY 来完成通配地址的设置。
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4 通配地址 */
listen()-监听socket
初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求。通过 listen 函数,可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核,这个套接字用于“被”请求。
#include <sys/socket.h>
//成功返回0,失败返回-1并设置errno
int listen(int sockfd, int backlog);
第一个参数 socketfd 为套接字描述符。
第二个参数 backlog提示内核监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将受到ECONNREFUSED错误信息。(在内核版本2.2之前的Linux中,backlog参数是指所有处于半连接状态和完全连接状态的socket的上限。在此之后,他只表示处于完全连接状态的socket上限,处于半连接状态的socket的上限则由/proc/sys/net/ipv4/tcp_max_syn_backlog内核参数定义)。典型值为5。
accept()-接受连接
accept 这个函数的作用就是连接建立之后,操作系统内核和应用程序之间的桥梁。
#include <sys/types.h>
#include <sys/socket.h>
//成功返回一个新的连接socket,失败返回-1;
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
sockfd参数是执行过listen系统调用的监听socket。
addr参数用来获取被接受端socket地址,该socket地址的长度由addrlen参数指出。
accept成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可通过读写该socket来与被接受连接对应的客户端通信。accept失败返回-1并设置errno。
accetp只是从监听队列中取出连接,而不论连接处于何种状态,更不关心任何网络状况的变化。
reference
[1] 深入理解计算机系统
[2] 极客时间 · 网络编程实战
[3] Linux高性能服务器编程