网络编程之TCP基础
TCP建立连接简单示例
服务端Socket
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <malloc.h>
#include <iostream>
int main(int argc, char * argv[]) {
/* 服务器创建 socket 连接 */
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
/* 地址结构体 */
struct sockaddr_in serv_address;
memset(&serv_address,0,sizeof(serv_address));
/* 地址结构数据填充 */
serv_address.sin_family = AF_INET; /* 选择协议族为IPV4 */
serv_address.sin_port = htons(9000); /* 绑定我们自定义的端口号 */
serv_address.sin_addr.s_addr = htonl(INADDR_ANY); /* 监听本地所有的IP地址(所有网卡) */
/* 将填充好的地址结构体与socket进行绑定 */
bind(listen_fd, (struct sockaddr*)&serv_address, sizeof(serv_address));
/* 服务端启动客户端监听 */
listen(listen_fd, 32);
std::cout << "listen...... " << std::endl;
int connect = 1;
/* 进入等待连接客户端 */
while (connect-- != 0) {
/* accept 等待客户端连接服务器 socket,返回连接的 socket 套接字 */
int connect_fd = accept(listen_fd, (struct sockaddr*) nullptr, nullptr);
std::cout << "listen client." << std::endl;
char * buffer = (char*)malloc(1000);
memset(buffer, 0, 1000);
sprintf(buffer, "hello connect number %d", connect);
/* 发送消息给刚刚连接上的客户端套接字 */
write(connect_fd, buffer, strlen(buffer));
/* 关闭连接的套接字,与客户端断开连接 */
close(connect_fd);
}
close(listen_fd); //实际本简单范例走不到这里,这句暂时看起来没啥用
return 0;
}
客户端Socekt
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
int main(int argc, char * argv[]) {
/* 服务器创建 socket 连接 */
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
std::cout << "socket = " << client_fd << std::endl;
/* 地址结构体 */
struct sockaddr_in serv_address;
memset(&serv_address,0,sizeof(serv_address));
/* 地址结构数据填充 */
serv_address.sin_family = AF_INET; /* 选择协议族为IPV4 */
serv_address.sin_port = htons(9000); /* 绑定我们自定义的端口号 */
/* 客户端要连接的服务地址,使用 inet_pton 将 字符串地址格式化成 sin_addr 格式 */
inet_pton(AF_INET,"192.168.1.126",&serv_address.sin_addr);
/* 连接到服务器 */
if(connect(client_fd,(struct sockaddr*)&serv_address,sizeof(serv_address)) < 0) {
exit(1);
}
/* 设置数据接收缓存 buffer */
char read_buffer[1000];
/* 等待数据到来 */
int read_len = read(client_fd,read_buffer,1000);
printf("read len = %d, context = %s\n", read_len, read_buffer);
/* 关闭套接字 */
close(client_fd);
std::cout << "client exit." << std::endl;
return 0;
}
TCP服务端和客户端建立连接过程的示意图如下:
TCP三次握手
TCP三次握手就是TCP建立连接前发送的三个空的只包含协议头的空包,两边的TCP连接通过解析协议头的标志位建立进行连接的状态转换,协议头定义如下:
其中状态转换的标志位是CWR
、ECE
、URG
、ACK
、PSH
、RST
、SYN
、FIN
这几个标志,这里边控制TCP连接的是ACK
和SYN
这两个标志,连接过程时序图如下:
- 客户端在调用
connect
函数,给服务器发送 了一个SYN标志位置位
的无包体TCP数据包,表示发起TCP的连接请求。 - 服务器在启动监听(调用listen)之后会一直在
accpet
函数处等待连接,当服务器收到一个仅置位SYN位
的空包之后,服务器返回一个SYN和ACK标志位都置位
的无包体TCP数据包,表示接受连接请求。 - 客户端收到服务器发送回来的
SYN和ACK标志位都置位
的数据包之后,再次发送一个ACK标志置位
的数据包,表示可以开始通信了,至此服务器和客户端两端建立连接。
TCP四次挥手
在断开连接时服务器和客户端进行四次后手,在四次挥手中确定断开连接状态。
- 断开连接的一方开始进行断开数据包的发送,例如,由服务器发起断开连接的请求就由服务器发送
ACK和FIN两个标志位置位
的空数据包。 - 客户端收到
ACK和FIN两个标志位置位
的空数据包之后首先回复一个ACK标志位置位
的空数据包,然后紧接着和服务端一样发送一个ACK和FIN两个标志位置位
的空数据包。 - 当服务收到由客户端发送的
ACK和FIN两个标志位置位
的空数据包后也同样回复一个ACK标志位置位
的空数据包,至此连接断开。
TCP状态
状态转换
-
不论是客户端还是服务最开始的状态都是
CLOSED(关闭)
状态,然后服务器启动后进入LISTENT(监听)
状态。
-
客户端开始连接服务器发送SYN置位的数据包,客户端进入
SYN_SENT(SYN发送状态)
。
-
服务器端收到数据包之后发送ACK和SYN置位数据包,服务器进入
SYN_RECV(SYN接收状态)
。
-
客户端收到ACK和SYN置位数据包后回复ACK置位数据包,客户端进入
ESTABLISHED(建立连接状态)
。
-
服务器端接收到客户端的第三次握手ACK数据包之后,也进入
ESTABLISHED(建立连接状态)
。
-
服务器端调用close关闭之后发送FIN和ACK置位的空数据包,服务器端进入
FIN_WAIT_1(FIN等待状态)
,该状态表示主动关闭socket的一方发送了FIN数据包,但还没有收到回复的ACK数据包。
-
客户端收到FIN数据包之后正常情况立即回复一个ACK数据给服务器,客户进入
CLOSE_WAIT(等待关闭状态)
。
-
服务器端收到客户端回复的ACK数据包之后变为
FIN_WAIT_2(FIN等待状态)
,正常情况下服务器端应该很快进入此状态,不会在FIN_WAIT_1
状态停留过长。客户端应该在CLOSE_WAIT
状态之后,调用read函数或者调用close函数关闭客户端的连接,客户端进入LAST_ACK(最后ACK确认状态)
,并发送FIN和ACK置位数据包。
-
服务器收到客户端发来的FIN和ACK置位数据包之后,回复一个ACK置位数据包,并进入
TIME_WAIT(等待状态)
。
-
客户端在收到服务器发送的最后一个ACK数据包之后表示连接彻底断开,此时客户端进入初始
CLOSED状态
。
-
服务进入
TIME_WAIT状态
后会等待1~4
分钟然后在进入CLOSED状态
。
-
这里还有一个
CLOSING状态
,这个状态一般很少见到。产生的原因是当服务器发送FIN数据包之后,会等待客户端返回ACK数据,但是客户端也在同一个时间结束连接,此时,服务器并没有等来ACK数据包而是等到了一个FIN数据,这种状态下就变成了CLOSING状态
,因此,CLOSING状态
应该是FIN_WAIT_1状态
后的跳转状态,并跳转到TIME_WAIT状态
。
TIME_WAIT状态
TIME_WAIT状态
会在1~4分钟左右进入CLOSED状态
,需要注意的是如果要进行TCP连接,当某个已经建立过连接的Socket在结束后停留在了TIME_WAIT状态
,再次进行连接(连接的地址和端口都一致)
调用bind()函数
,此时会出现错误bind()返回-1
,错误代码号98
提示的错误信息为Address already in use
,这里停留1~4
分钟的时间等于2倍MSL(最长数据包生命周期)
。
这种机制存在的原因有两点:
-
可靠的实现TCP全双工的终止
在上面的情况中如果服务器最后发送的
ACK包因为某种原因丢失
,那么客户端一定会重新发送FIN包
,并且继续在LAST_ACK状态
等着收最后一个回复ACK数据包
。由于服务器处于TIME_WAIT状态
服务器还会处理FIN包并正常返回ACK数据包
。但如果服务收到FIN数据包
后直接就关闭了,在发生ACK包因为某种原因丢失
情况客户端即使重新发送FIN包
也无法等到ACK数据包
了,而是收到RST(重新连接状态)
,从而就没有完成完整的4次挥手。 -
允许老的重复的TCP数据包在网络中消逝
SO_REUSEADDR选项
当服务器进入TIME_WAIT状态
后需要等待一段时间后才能从新bind()
,可以使用setsockopt()函数
的SO_REUSEADDR选项
进行处理,SO_REUSEADDR选项
表示后续绑定的地址和端口
可以重复绑定,该函数的使用示例如下:
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <malloc.h>
#include <iostream>
int main(int argc, char * argv[]) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_address;
memset(&serv_address,0,sizeof(serv_address));
serv_address.sin_family = AF_INET;
serv_address.sin_port = htons(9000);
serv_address.sin_addr.s_addr = htonl(INADDR_ANY);
int reuseaddr = 1;
if(setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, (const void *)&reuseaddr, sizeof(reuseaddr)) == -1) { /* 错误处理 */ }
bind(listen_fd, (struct sockaddr*)&serv_address, sizeof(serv_address));
return 0;
}
setsockopt()
函数用在服务器端,且用在调用socket()
之后和调用bind()
之前,其中SO_REUSEADDR
参数表示的含义和功能如下:
- 允许启动一个监听服务器并捆绑其端口,即使处于
TIME_WAIT状态
也能绑定成功。 - 允许一台服务器启动多个服务器程序并绑定同一个端口 ,只要每个实服务器程序绑定不同的本地IP地址即可。
- 允许单个进程内创建多个套接字并绑定同一个端口,只要每次绑定不同的本地IP地址即可。
- 允许完全重复的绑定,当一个地址和端口已经绑定到某个套接字上时,同样的IP地址和端口还可以绑定到另一个套接字上,但要传输协议支持,一般是UDP可用TCP不可用此特性。
Listen队列
listen第二参数
listen函数
有两个参数,一个是创建的要监听的Socket号,另一个是backlog
参数,在服务器启动监听后操作系统给Socket创建两个队列未完成连接队列
和已完成队列
,而backlog就是整两个队列的总和
。
-
未完成连接队列
当客户端发送TCP连接三次握手的第一次握手时,即发送一个
SYN置位的数据包
给服务器的,这种连接处于半连接状态
,在半连接状态下的连接都在这个队列中。 -
已完成连接队列
当客户端和服务器端完成三次握手状态变为
ESTABLISHED状态
,每个已经完成三次握手的连接都会从未完成连接队列中放如此队列中
。
如果两个队列之和等于backlog参数后,再有一个客户发送SYN请求时,服务器会忽略这个SYN请求包不给回应,一般情况下这个参数的值可以设置成300
。
connect返回时间
当客户端收到第二次握手包,即服务器返回给客户端的ACK和SYN位置数据包
收到之后函数返回。
RTT时间
RTT是未完成队列中
任意一个未完成的连接在队列中留存的时间,这个时间取决于客户端和服务器。对于客户端来说,这个RTT时间是第一次握手到第二次握手的时间
。对于服务器端来说,这个RTT时间是第二次握手到第三次握手的时间
。如果有客户端只发送第一次握手连接而没有发送第三次握手那么服务器未完成连接队列中会慢慢被沾满,从而导致连接会失败。但是,并不会一只占用队列只是时间比较长大概有75秒
左右就会被系统干掉。
accept函数
accept()
函数的作用就是从已完成连接队列
中取出来一个已经建立了连接的Socket返回给进程,如果队列中是空的则一直阻塞在此函数,直到队列中有数据可以获取。从放到已完成连接队列中
到取出accept()返回
这需要一个时间,如果这个时候客户端发送了数据,这写数据将保存到已连接Socket的缓冲区中,这个缓冲区的大小就决定了这段时间可接受的缓存数据大小,如果长时间不处理可能造成数据丢失,因此,要尽快处理accept()
函数,即尽快从已完成连接队列中取出连接Socket
。