Unix Network Programming - 1. 阻塞式I/O模型 EchoClient/EchoServer

1 了解套接字函数

熟悉套接字地址
端口和地址, 必须按照网络字节序来维护
地址转换, 将字符串型地址转换为32位的网络字节序2进制值, 多个地址转换函数的抉择, 转换出问题题的表征
read/write, stdio缓冲区带来的问题, 解决方案 - 手动管理缓冲区

1
2
3
4
5
6
7
struct sockaddr_in{
uint_8 sin_len; // 可忽略, 必要时在了解
sa_family_t sin_family; // 套接字地址协议域 AF_INET
in_port sin_port; // 套接字地址端口 uint16_t, 16位无符号整形 注意为网络字节序
struct in_addr sin_addr; // 套接字地址 uint32_t, 32位无符号整形 注意为网络字节序
char sin_zero[8]; // 可忽略, 必要时在了解
}

avatar
avatar

socket, 创建基本套接字(主动套接字)

1
2
3
4
5
6
#include <sys/socket.h>
int socket(int family, int type, int protocal); // return sockfd/-1

// @family 协议域 AF_INET
// @type 套接字类型 SOCK_STREAM
// @protocal 协议类型常值 填0表示通过family和type来确定协议类型常值, 例如AF_INET/SOCK_STREAM组合表示使用IPv4/TCP

connect, 连接主动套接字

1
2
3
4
5
6
#include <sys/socket.h>
int connect(int local_sockfd, const struct sockaddr* server_addr, socklen_t addrlen); // return 0/-1

// @local_sockfd 套接字描述符
// @server_addr 套接字地址
// @addrlen 该套接字结构地址大小 static_cast<socklen_t>(sizeof(sockaddr))

使用该函数将发生TCP的三次握手过程, 在连接确定成功或失败时才会返回, 意味着在此期间将会阻塞.
连接过程中, 由于客户端和服务器之间是透明的, 客户端并不知道服务器的状态, 唯一所能知道的就是服务器的套接字地址和端口, 那么在connect期间就可能会出现问题:

  1. 客户端压根就没有找到这个服务器
    这时, 客户端并不会轻易放弃连接, 并为自己设置了超时, 如果时间范围内没有连到服务器, 会休息一会, 然后进行重连, 总共耗时发生75s后放弃连接, 返回一个ETIMEDOUT错误, 表示超时.

  2. 客户端找到了服务器, 但服务器并未准备为其服务
    客户端所指定的端口, 在服务器上没有使用该端口的进程, 无法提供服务, 但服务器并不会忽略客户端的好意来访, 会告诉客户端RST信号, 此时客户端就会立刻返回ECONNREFUSED错误, 表示服务器拒绝连接.

  3. 服务器不可达
    客户端收到某刻路由器发出的’目的地不可达’错误, 此时客户端会保存这个错误, 然后按第一种方式重连. 最后实在连接不上, 将保存的这个错误以EHOSTUNREACHENETUNREACH错误返回给进程.

connect连接过程中的状态迁移:
CLOSED —[send SYN to server]—> SYN_SEND —[connect success]—> ESTABLISHED

若connect连接失败, sockfd将不能在被使用, 需要关闭后新建sockfd.

bind, 套接字与地址绑定

1
2
3
4
5
6
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* spec_addr, socklen_t addrlen); // return 0/-1

// @sockfd 套接字描述符
// @spec_addr 指定协议地址, 准备将其绑定到sockfd
// @addrlen 该套接字结构地址大小 static_cast<socklen_t>(sizeof(sockaddr))

spec_addr可以指定协议域, ip地址和端口
协议域应该和创建sockfd时使用的协议域相同

ip地址和端口均可指定可不指定, 所以将会得到下面四种情况

指定ip地址 指定端口 结果
0 0 内核选择IP地址和端口
0 1 内核选择IP地址, 进程指定端口
1 0 进程指定IP地址, 内核选择端口
1 1 进程指定IP地址和端口

不指定端口时, 内核在bind时选择一个临时端口, 但该端口并不会随着spec_addr返回(const限制), 应该用getsockname来返回协议地址
不指定IP地址时, 内核将在能够确定IP地址时(TCP连接或者收到UDP的数据包时)再确定
指定IP地址, 内核将仅为该连接时使用该IP地址的客户服务

一般会不指定IP地址, 但指定端口来为所有明确知道该端口存在服务的客户提供相应服务

bind可能会失败, 常见错误是EADDRINUSE, 表示端口被占用, 此时可以使用SO_REUSEADDRSO_REUSEPORT来解决, 前提是知道这两个选项做了什么.

listen, 监听被动套接字

1
2
3
4
5
#include <sys/socket.h>
int listen(int sockfd, int backlog); // return 0/-1

// @sockfd 套接字描述符
// @backlog 完成连接队列和未完成连接队列长度之和不超过backlog

listen将一个主动套接字转换为一个被动套接字, 表示内核应该接受指向该套接字的连接请求.

内核将为listen维护两个队列: 未完成连接队列和完成连接队列.
当客户端的一个SYN到来时, 内核在未完成连接队列中创建一个新项, 并且响应这个SYN, 回复SYN和ACK, 在此期间连接状态将一直保存在未完成连接队列中, 直到三次握手的第三个分节来自客户端的SYN和ACK响应, 此时连接建立完成, 该连接将被移至已连接队列, accept将会从已连接队列中获取队列头部的连接处理, 如果已连接队列为空, 服务进程将投入睡眠.

当一个SYN到达时, 这两个队列是满的, 那将会忽略这个SYN, 不返回任何东西, 依赖于客户端的重连机制, 等待队列存在空闲时来处理这个连接.

1
2
3
4
5
6
#include <sys/socket.h>
int accept(int listenfd, struct sockaddr* client_addr, socklen_t* addrlen); // return connnected_fd/-1

// @sockfd 套接字描述符
// @client_addr 用来返回已连接的客户端协议地址
// @addrlen (注意这里是指针变量)accept前为套接字地址结构大小, accept后为由内核存放在该套接字地址结构内的确切字节数

listenfd为服务器的监听描述符, accept监听此描述符上的事件, 返回一个全新的connected_fd连接描述符.
一个服务通常只有一个listenfd, 在该服务的生命周期内一直存在, 而connected_fd可以存在多个, 在不必要的时候关闭这个描述符.
关闭listenfd意味着服务器也即将关闭.

如果不关心客户端的协议地址, 那么可以将client_addr和addrlen设置为空值.

close, 关闭套接字

1
2
3
4
#include <sys/socket.h>
int close(int sockfd); // return 0/-1

// @sockfd 待关闭套接字描述符

close是sockfd上的引用计数-1, 当引用计数为0时, 将对应套接字标记为关闭, 并极力返回到调用进程. 此时该描述符将不可在被使用, 而内核将发送在该套接字上已排队等待发送的数据, 发送完毕后发生正常的TCP断开连接(四次挥手).

如果需要立即关闭这个套接字, 那么应该使用shutdown函数, 见下.

1
2
3
int shutdown(int sockfd, int howto); // return 0/-1
close仅将连接计数-1, 在计数为0时关闭连接, shutdown则是直接关闭
close将关闭读写两个方向上的传输, 而shutdown可以通过howto来控制读写单功能上的关闭

getsockname/getpeername, 获得已建立连接的对端的协议地址

getsockname用于获取已经建立TCP连接的本地IP地址和本地端口号.
getpeername用于获取已经建立TCP连接的对端IP地址和其端口号.

关于fork和exec系函数

1
2
3
4
#include <unistd.h>
pid_t fork(); // return 在子进程中返回0, 在父进程中返回子进程ID, 出错返回-1

fork将产生两个一模一样的进程, 如在accept成功后调用fork来分离业务, 那么在父进程中存在一个监听描述符和一个已连接描述符, 在子进程中同样存在一个相同的监听描述符和已连接描述符, 此时它俩的引用计数都是2, 需要使用close, 在父进程中关闭这个已连接描述符, 在子进程中关闭这个监听描述符, 使它俩的引用计数都变为1.

如果需要fork的进程执行其他程序, 那么需要exec系函数帮助, exec系把当前进程映像替换成新的程序文件, 而且该新进程通常从main函数开始执行, 进程ID不会发生改变.

send/recv

read/write三个变体:
recv/send 允许通过第四个参数从级才能拿到内核传递标志
readv/writev允许指定往其中输入数据或从中输出数据的缓冲区向量
recvmsg/sendmsg结合其他I/O函数的所有特性, 并具备接受和发送辅助数据的新能力

recv/send
send MSG_DONTROUTE 绕过路由表查找
recv/send MSG_DONTWAIT 仅本次操作非阻塞
recv/send MSG_OOD 发送或接受带外数据
recv MSG_PEEK 窥看外来消息
recv MSG_WAITALL 等待所有数据

2 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include "Common.h"

void str_echo(int fd) {
char buf[MAXLINE];

while (1) {
int n = read(fd, buf, MAXLINE); // 子进程阻塞与从相应fd读取数据
if(n > 0) write(fd, buf, n);

if (n <= 0 && errno == EINTR) {
std::cout << "read errno -> EINTR" << std::endl;
}
else if (n <= 0)
{
std::cout << "read return <= 0" << std::endl;
break;
}
}
}

void sig_chld(int signo) {
pid_t pid;
int stat;

// WHOHANG选项, 告知waitpid在有尚未终止的子进程在运行时不要阻塞
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
std::cout << "child " << pid << " terminated\n" << std::endl;
}
return;
}

int main() {
int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sockfd < 0) {
std::cout << "create blank socket failure." << std::endl;
}

sockaddr_in socket_addr;
bzero(&socket_addr, sizeof(socket_addr));
socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 等到TCP连接成功后选择IP地址
socket_addr.sin_port = htons(SERVER_PORT);

if (bind(listen_sockfd, (sockaddr*)& socket_addr, sizeof(socket_addr)) < 0) {
std::cout << "bind socket address failure." << std::endl;
}

if (listen(listen_sockfd, LISTENQ) < 0) {
std::cout << "listening socket create failure." << std::endl;
}

signal(SIGCHLD, sig_chld); // 处理SIGCHLD信号, 避免进程成为僵尸进程

sockaddr_in client_addr;
socklen_t clilen = sizeof(client_addr);
while (1) {
int connect_sockfd = accept(listen_sockfd, (sockaddr*)&client_addr,&clilen); // 父进程将再次阻塞与accept
if (connect_sockfd < 0) {
if (errno == EINTR) continue;
else std::cout << "connected socket create failure." << std::endl;
}

pid_t childpid = fork();
if (childpid == 0) {
close(listen_sockfd); // 子进程不需要这个监听套接字
str_echo(connect_sockfd);
exit(0); // 子进程退出后, 向父进程发送SIGCHLD, 同时触发四次挥手后两个分节
}
close(connect_sockfd); // 父进程不需要这个连接套接字
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void str_cli(FILE* fp, int sockfd) {
char sendbuffer[MAXLINE], recvbuffer[MAXLINE];
while (fgets(sendbuffer, MAXLINE, fp) != NULL) { // 客户端阻塞于从标准输入读取数据
write(sockfd, sendbuffer, strlen(sendbuffer));

if (read(sockfd, recvbuffer, MAXLINE) == 0) { // 客户端再次阻塞于从socket读取数据
std::cout << "server terminated prematurely" << std::endl;
}

fputs(recvbuffer, stdout);
}
}

int main(int argc, char** argv) {
if(argc != 2) return 0;

int sockfd = socket(AF_INET, SOCK_STREAM, 0); // IPv4, TCP
if (sockfd < 0) {
std::cout << "create blank socket failure." << std::endl;
}

sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT); // 将端口转换为网络字节序
inet_pton(AF_INET, argv[1], &server_addr.sin_addr); // 点分十进制到IP地址结构

if (connect(sockfd, (sockaddr*)& server_addr, sizeof(server_addr)) < 0) {
std::cout << "connect to server failure." << std::endl;
}

str_cli(stdin, sockfd);

exit(0);
// 客户端进程正常退出, 将关闭进程打开的所有描述符, 在关闭sockfd时, 将会向服务器发送一个FIN, 开始TCP断开连接四次挥手的前两个分节
// C: ESTABLISHED --[close(), send:FIN]--> FIN_WAIT_1 --[recv:ACK]--> FIN_WAIT_2 --[recv:FIN, send:ACK]--> TIME_WAIT --[2MSL timeout]--> CLOSED
// S: ESTABLISHED --[recv:FIN, send:ACK]--> CLOSE_WAIT --[close(), send:FIN]--> LAST_ACK --[recv:ACK]--> CLOSED

return 0;
}