socket连接池SocketPool分析(二):《UNPv1》复习(上):socket函数,三次握手,四次挥手
- 1. socket函数:
- 1.1. int socket(int family, int type, int protocol)
- 1.2. int connect(int sockfd, struct sockaddr *servaddr, socklen_t addrlen)
- 1.3. int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen)
- 1.4. int listen(int sockfd, int backlog)
- 1.5. int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
- 1.6. int close(int sockfd)
- 1.7. ssize_t read(int sockfd, const void* buf, size_t count)
- 1.8. ssize_t write(int sockfd, const void* buf, size_t count)
- 1.9. int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen)
- 1.10. int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen)
- 2. 三次握手和四次挥手
我在大学当中学完了C++写的第一个程序除了hello world就是《UNPv1》当中发送消息的这个程序了。本来以为复习这本书的时间会非常快,但是没想到在复习UNP这本书的过程中,发现了很多自己读大学的时候遗漏的知识点,很多以前并不明朗的概念也清晰起来。
下面对此做个总结。因为任何网络编程不可能绕开这本书。
socket函数:
- socket()
- connect()
- bind()
- listen()
- accept()
- close()
- read()
- write()
- getsockname()
- getpeername()
int socket(int family, int type, int protocol)
头文件
1 | #include <sys/socket.h> |
为了执行网络IO,一个进程要做的第一件事件就是创建一个socket。一个socket在linux下其实就是一个文件描述符,是一个int型的整数,这在windows下被称为句柄。这样看来,创建一个socket就对应于普通文件的打开操作。socket创建完成之后,还需要将ip地址和端口号描述给这个socket,这样以后收发消息就能直接使用这个int型的整数,在下文中我将给出例子,就算在read函数中没有传socket变量,直接传一个整数值,也能成功(前提是你知道这个整数值是一个socket,不过这也非常简单,因为socket的创建函数都是有顺序规律的)。
参数 | 含义 |
---|---|
family | 协议族 |
type | 套接字类型 |
protocol | 协议类型常值 |
只是使用socket的话,参数的含义并不需要记住,我创建socket
只记住以下的代码
1 | int sockFD_ = socket(AF_INET, SOCK_STREAM, 0); |
这是创建一个TCP的socket。
int connect(int sockfd, struct sockaddr *servaddr, socklen_t addrlen)
TCP使用connect
函数来建立与服务器的连接。对于客户端来说,只用三个函数socket()
,connect()
,close()
。但是真正的后台接口不会这么简单,后台接口都是从前端的API那里取到数据,然后写到数据库或者调用其他接口(也就是RPC),更多的是起到一个路由器的作用。所以后台接口会既有服务器的socket函数,也有客户端的socket函数。
这个struct sockaddr
当中必须含有服务器的ip地址和端口号(废话,要不然怎么连接?)。
1 | struct sockaddr_in sAddr; |
顺便看一下sockaddr_in
的地址和sockaddr
的结构:
1 | struct sockaddr_in { |
1 | struct sockaddr { |
在socket编程当中,这两个结构唯一的作用就是强制类型转换。
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen)
bind
函数用于服务器,将本地的ip地址和端口号赋予一个socket。一般来说服务器在启动之前必须使用bind
函数来绑定一个众所周知的端口,否则客户端凭什么找到它呢?
在《UNPv1》这本书当中提到了一个例外,就是RPC服务器,当使用了RPC服务器的时候,我们的server端就不需要使用bind
了,当未曾调用bind
的时候,那么在调用listen
函数的时候,内核就要为相应的套接字选择一个临时端口,而这个端口随后通过RPC服务器的RPC端口映射器进行注册,客户在connect
我们的server端之前,必须与端口映射器联系取得这个临时端口。那么server端有没有这个众所周知的端口就无关紧要了。
以我看来,这种形式类似于nginx的反向代理机制。
使用方法:
1 | struct sockaddr_in sAddr; |
int listen(int sockfd, int backlog)
当使用socket()
函数来创建套接字的时候,它被假设为一个主动套接字,也就是说,它被假设是客户端创建的,将被connect()
函数来使用。listen函数把这个套接字转为被动套接字。第二个参数backlog规定了内核为相应套接字排队的最大连接个数。
为了理解backlog,必须认识到内核为任何一个给定的监听套接字维护两个队列
- 1,未完成连接队列(incomplete connection queue)。队列中的每一个成员对应一个客户端的SYN分节,就是说客户端已经发出connnect连接了服务器,但是还在等待服务器完成三次握手。
- 2,已经完成连接队列(completed connection queue)。队列中的每一个成员对应一个已经连接上的客户端,早就完成了三路握手
- 两个队列之和不能超过backlog
如果多个客户连接差不多时间到达的话,系统内核在某个最大数目的限制(backlog)下把他们放入队列当中,然后每次返回一个给accept函数。这就意味着如果晚到的客户连接将阻塞在listen当中等待,这对于服务器的性能是很大的影响,这时候必须用某种方式重叠对各个客户的服务。就是做成并发服务器。
编写并发服务器有很多的技术:最简单的技术就是调用linux的fork函数,为每个客户创建一个子进程,或在服务器启动时预先fork一定数量的子进程。而成熟的模型是one loop per thread。是启动多个线程,每个线程启动一个事件驱动。
使用方法:
1 | int backlog = 10; |
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
这个函数第一个需要注意的地方就是它的第三个参数
1 | socklen_t *addrlen |
传递的是一个指针,为什么不直接使用socklen_t addrlen
呢?
原因是:
这在C语言当中是一个(value-result)的函数:当函数被调用的时候,结构的大小是一个值(value),它告诉内核该结构的大小,这样内核在写该结构时不至于越界;当函数返回时,结构的大小又是一个结果(result),它告诉进程内核在这个程序中存储了多少信息。这就相当于使用C++的引用来解决一个函数只能返回一个对象的问题。
因为addrlen的值以后可能会用到,所以就返回给外部。
第二个需要注意的地方就在于这个函数的返回值,这个函数返回的是一个套接字,代表与客户端的连接。
通过对这个返回的套接字调用read()
函数和write()
函数就能够和客户端通信,就类似于普通文件的读写I/O操作了。
这是一个慢系统调用(slow system call),是值调用可能永远无法返回的系统调用。当客户端并没有执行connect的时候,这个accept函数可能就永远无法返回。
使用方法:
1 | struct sockaddr_in sAddr; |
int close(int sockfd)
用来关闭套接字。
使用方法:
1 | if (close(sockFD_) < 0) { |
ssize_t read(int sockfd, const void* buf, size_t count)
read()
函数从sockfd当中读取数据,放入缓冲区buf当中,count指的是从sockfd中读取的长度,当读取成功时,返回实际读取到的字节数,如果返回值是0,表示已经读取到文件的结束了,小于0是读取错误。
使用方法:
1 | const int MAXLINE = 1024; |
ssize_t write(int sockfd, const void* buf, size_t count)
write()
函数从buf当中读取数据,count是从buf当中读取的长度,再写入sockfd,写入成功时,返回的是往sockfd当中写入的长度,失败返回-1.
使用方法:
1 | const int MAXLINE = 1024; |
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen)
- 在没有调用bind的客户端,当connect返回之后,使用getsockname得到由内核赋予客户端的本地ip地址和本地端口号
- 在调用的bind的服务器端,使用getsockname得到bind赋予的ip地址和端口号。当然,传给getsockname的是监听套接字,而不是accept返回的套接字。其实这个函数对于服务器端是没有意义的。
使用方法:
1 | struct sockaddr_in sAddr; |
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen)
获取连接的另一方的ip地址和端口号。
这个函数对于客户端没有意义,因为创建客户端,使用connect()
函数的时候必定指定服务器的ip地址和端口号,当然用这个获取已知的ip地址和端口号没有任何意义了。
有意义的用法在于服务器端,能够把accept()
函数返回的套接字传给getpeername()
函数,获取客户端的ip地址和端口号。
使用方法:
1 | struct sockaddr_in sAddr; |
三次握手和四次挥手
三次握手
1,当服务器启动listen函数的时候,表示准备好接受外来的连接,称为被动打开
2,客户端通过connect函数发起主动打开,触发了连接请求,向服务器发送了SYN J,这时connect进入阻塞状态;
3,服务端的accept接受到SYN J,向客户端发送SYN K ,ACK J+1,这时accept进入阻塞状态;
4,客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回,至此三次握手完毕,连接建立。
总结:客户端的connect在三次握手的第二个次返回,而服务器端的accept在三次握手的第三次返回。
四次挥手
1,客户端或者服务器主动调用close关闭连接,称为主动关闭。发起端于是发送一个FIN。
2,对应端接受FIN,执行被动关闭。对这个FIN进行确认,回复一个ACK,它的接收也作为文件结束符传递给应用进程
3,过了一段时间后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN
4,接收到这个FIN的源发送端TCP对它进行确认,回复一个ACK
这样每个方向上都有一个FIN和ACK。
半开连接
当客户端与服务器建立起正常的TCP连接后,如果客户主机掉线(网线断开)、电源掉电、或系统崩溃,服务器进程将永远不会知道(通过我们常用的select,epoll监测不到断开或错误事件),如果不主动处理或重启系统的话对于服务端来说会一直维持着这个连接,任凭服务端进程如何望穿秋水,也永远再等不到客户端的任何回应。这种情况就是半开连接,浪费了服务器端可用的文件描述符。
其实我们可以在应用层模拟SO_KEEPALIVE的方式,用心跳包来模拟保活探测分节。由于服务器通常要承担成千上万的并发连接,所以肯定是由客户端在应用层进行心跳来模拟保活探测分节,客户端多次收不到服务器的响应时可终止此TCP连接,而服务端可监测客户端的心跳包,若在一定时间间隔内未收到任何来自客户端的心跳包则可以终止此TCP连接,这样就有效避免了TCP半开连接的情况