本章将讨论如何优雅地断开相互连接的套接字。之前用的方法不够优雅是因为,我们是调用close或closesocket函数单方面断开连接的。
TCP中的断开连接过程比建立连接过程更重要,因为连接过程中一般不会出现大的变数,但断开过程有可能发生预想不到的情况,因此应准确掌控。只有掌握了半关闭( Half-close ),才能明确断开过程。
Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。如图所示:
图中描述的是 2 台主机正在进行双向通信,主机 A 发送完最后的数据后,调用 close 函数断开了最后的连接,之后主机 A 无法再接受主机 B 传输的数据。实际上,是完全无法调用与接受数据相关的函数。最终,由主机 B 传输的、主机 A 必须要接受的数据也销毁了。
为了解决这类问题,「只关闭一部分数据交换中使用的流」的方法应运而生。断开一部分连接是指,可以传输数据但是无法接收,或可以接受数据但无法传输。顾名思义就是只关闭流的一半。
两台主机通过套接字建立连接后进人可交换数据的状态,又称“流形成的状态”。也就是把建立套接字后可交换数据的状态看作一种流。此处的流可以比作水流。水朝着一个方向流动,同样,在套接字的流中,数据也只能向一个方向移动。因此,为了进行双向通信,因此需要下图所示的2个流。
一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。
shutdown
函数就用来关闭其中1个流。
#include
int shutdown(int sock, int howto);
/*
成功时返回 0 ,失败时返回 -1
sock: 需要断开套接字文件描述符
howto: 传递断开方式信息
*/
调用上述函数时,第二个参数决定断开连接的方式,其值如下所示:
SHUT_RD
:断开输入流SHUT_WR
:断开输出流SHUT_RDWR
:同时断开I/O流若参数是SHUT_RD
,则断开输入流,套接字无法接收数据,即使输入缓冲还有数据也会抹去。
若参数是SHUT_WR
,则断开输出流。但如果输出缓冲中还有未传输的数据,还是会传到目标主机。
最后,若参数是SHUT_RDWR
,则同时中断 I/O 流。这相当于分 2 次调用 shutdown ,其中一次以 SHUT_RD
为参数,另一次以 SHUT_WR
为参数。
如果保持足够的时间间隔,完成数据交换后再断开连接,这时就没必要使用半关闭。但要考虑如下情况:
一旦客户端连接到服务器,服务器将约定的文件传输给客户端,客户端收到后发送字符串「Thank you」给服务器端。
此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的还嫌难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序阻塞。
是否可以让服务器和客户端约定一个代表文件尾的字符?
这种方式也有问题,因为这意味这文件中不能有与约定字符相同的内容。为了解决该问题,服务端应最后向客户端传递 EOF 表示文件传输结束。客户端通过函数返回值接受 EOF ,这样可以避免与文件内容冲突。那么问题来了,服务端如何传递 EOF ?
断开输出流时向主机传输 EOF。
服务器断开输出流表示不会传数据了,此时就给客户端传输EOF。
当然,调用 close 函数的同时关闭 I/O 流,这样也会向对方发送 EOF 。但此时无法再接受对方传输的数据。换言之,若调用 close
函数关闭流,就无法接受客户端最后发送的字符串「Thank you」。这时需要调用 shutdown
函数,只关闭服务器的输出流。这样既可以发送 EOF ,同时又保留了输入流**。下面实现收发文件的服务器端/客户端。
上述文件传输服务器端和客户端的数据流可以整理如图:
下面的代码为了便于分析,省略了大量错误处理代码,实际编写中不应省略。
// file_server.c
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sd, clnt_sd;
FILE *fp;
char buf[BUF_SIZE];
int read_cnt, read_len;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
if (argc != 2)
{
printf("Usage : %s \n" , argv[0]);
exit(1);
}
fp = fopen("file_server.c", "rb");
serv_sd = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
bind(serv_sd, (struct sockaddr *)&serv_adr, sizeof(serv_adr));
listen(serv_sd, 5);
clnt_adr_sz = sizeof(clnt_adr);
clnt_sd = accept(serv_sd, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);
while (1)
{
//从文件流中读取数据,buffer为接收数据的地址,size为一个单元的大小,count为单元个数,stream为文件流
//返回实际读取的单元个数
read_cnt = fread((void *)buf, 1, BUF_SIZE, fp);
if (read_cnt < BUF_SIZE)
{
write(clnt_sd, buf, read_cnt);
break;
}
write(clnt_sd, buf, BUF_SIZE);
}
shutdown(clnt_sd, SHUT_WR);
read_len = read(clnt_sd, buf, BUF_SIZE);
buf[read_len] = 0; // 结尾
printf("Message from client: %s \n", buf);
fclose(fp);
close(clnt_sd);
close(serv_sd);
return 0;
}
// file_client.c
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
int main(int argc, char* argv[]) {
int sock;
FILE *fp;
char buf[BUF_SIZE];
int read_cnt;
struct sockaddr_in serv_adr;
if (argc != 3) {
printf("Usage : %s " , argv[0]);
exit(1);
}
fp = fopen("receive.txt", "wb");
sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
while ((read_cnt = read(sock, buf, BUF_SIZE)) != 0)
fwrite((void*)buf, 1, read_cnt, fp);
puts("Received file data");
write(sock, "Thank you", 10);
fclose(fp);
close(sock);
return 0;
}
编译运行:
gcc file_client.c -o fclient
gcc file_server.c -o fserver
./fserver 9190
./fclient 127.0.0.1 9190
结果:
上述程序将file_server.c
文件通过服务器端发送给客户端,当文件传完后就shutdown
了客户端套接字的输出流,但是输入流还保存着,所以最终服务器会收到来自客户端的感谢信息。而且receive.txt
存储了传送的内容。
暂略。
DNS 是对IP地址和域名进行相互转换的系统,其核心是 DNS 服务器。
域名就是我们常常在地址栏里面输入的地址,将比较难记忆的IP地址变成人类容易理解的信息。
可以理解为一个大数据库,相当于一个字典,可以查询出某一个域名对应的IP地址。下图展示了这一过程:
上图展示了默认DNS服务器无法解析主机询问的域名IP地址时的应答过程。可以看出,默认DNS服务器收到自己无法解析的请求时,向上级DNS服务器询问。通过这种方式逐级向上传递信息,到达顶级DNS服务器——根DNS服务器时,它知道该向哪个DNS服务器询问。向下级DNS传递解析请求,得到IP地址后原路返回,最后将解析的IP地址传递到发起请求的主机。DNS就是这样层次化管理的一种分布式数据库系统。
一句话,需要。因为IP地址可能经常改变,而且也不容易记忆,通过域名可以随时更改解析,达到更换IP的目的。
使用以下函数可以通过传递字符串格式的域名获取IP地址。
#include
struct hostent * gethostbyname(const char * hostname);
/*
成功时返回 hostent 结构体地址,失败时返回 NULL 指针
*/
这个函数使用方便,只要传递字符串,就可以返回域名对应的IP地址。只是返回时,地址信息装入hostent 结构体。此结构体的定义如下:
struct hostent
{
char *h_name; /* Official name of host. */
char **h_aliases; /* Alias list. */
int h_addrtype; /* Host address type. */
int h_length; /* Length of address. */
char **h_addr_list; /* List of addresses from name server. */
};
从上述结构体可以看出,不止返回IP信息,同时还带着其他信息一起返回。域名转换成IP时只需要关注h_addr_list 。下面简要说明上述结构体的成员:
调用 gethostbyname
函数后,返回的结构体变量如图所示:
下列示例主要演示gethostbyname函数的应用,并说明hostent结构体变量的特性。
// gethostbyname.c
#include
#include
#include
#include
#include
void error_handling(char *message);
int main (int argc, char* argv[]) {
int i;
struct hostent *host;
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
// 把参数传递给函数,返回结构体
host = gethostbyname(argv[1]);
if (!host) error_handling("gethost... error");
// 输出官方域名
printf("Official name: %s \n", host->h_name);
for (i = 0; host->h_aliases[i]; i++)
printf("Aliases %d: %s \n", i + 1, host->h_aliases[i]);
printf("Address type: %s \n",
(host->h_addrtype == AF_INET) ? "AF_INET" : "AF_INET6");
// 输出ip地址信息
for (i = 0; host->h_addr_list[i]; i++)
printf("IP addr %d: %s \n", i + 1,
inet_ntoa(*(struct in_addr*)host->h_addr_list[i]));
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc gethostbyname.c -o hostname
./hostname www.baidu.com
【网络原理】详解访问域名 www.baidu.com 中的DNS解析过程
仔细阅读这一段代码:
inet_ntoa(*(struct in_addr *)host->h_addr_list[i])
若只看 hostent
的定义,结构体成员 h_addr_list
指向字符串指针数组(由多个字符串地址构成的数组)。但是字符串指针数组保存的元素实际指向的是 in_addr
结构体变量中地址值而非字符串(保存的是结构体变量的地址!!!!)。
所以说:(struct in_addr *)host->h_addr_list[i]
其实是一个指针,然后用*
取具体的值,再使用inet_ntoa
将十六进制整数的数转换为点分十进制字符串,注意这里的输入必须是大端序。
可能对in_addr不是很熟悉,其实在第三章就讲过:
就是存放32位IP地址的结构体。
因为honsent
结构体并非只为IPv4准备,所以h_addr_list
指向的数组类型并不是in_addr
结构体的指针数组,而采用了char指针,表示无法明确指出指针类型。
使用gethostbyaddr
函数利用IP地址获取域相关信息:
#include
struct hostent *gethostbyaddr(const char *addr, socklen_t len, int family);
/*
成功时返回 hostent 结构体变量地址值,失败时返回 NULL 指针
addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPV4 地址之外的全部信息,该变量
的类型声明为 char 指针
len: 向第一个参数传递的地址信息的字节数,IPV4时为 4 ,IPV6 时为16.
family: 传递地址族信息,ipv4 是 AF_INET ,IPV6是 AF_INET6
*/
下面的示例演示该函数的用法:
#include
#include
#include
#include
#include
#include
void error_handling(char* message);
int main(int argc, char* argv[]) {
int i;
struct hostent *host;
struct sockaddr_in addr;
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
memset(&addr, 0, sizeof(addr));
addr.sin_addr.s_addr = inet_addr(argv[1]);
host = gethostbyaddr((char *)&addr.sin_addr, 4, AF_INET);
if (!host) error_handling("gethost... error");
// 输出官方域名
printf("Official name: %s \n", host->h_name);
// Aliases 貌似是解析的 cname 域名?
for (i = 0; host->h_aliases[i]; i++)
printf("Aliases %d: %s \n", i + 1, host->h_aliases[i]);
//看看是不是ipv4
printf("Address type: %s \n",
(host->h_addrtype == AF_INET) ? "AF_INET" : "AF_INET6");
// 输出ip地址信息
for (i = 0; host->h_addr_list[i]; i++)
printf("IP addr %d: %s \n", i + 1,
inet_ntoa(*(struct in_addr *)host->h_addr_list[i]));
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc gethostbyaddr.c -o hostaddr
./hostaddr 8.8.8.8
结果:
从图上可以看出, 8.8.8.8 这个IP地址是谷歌的。
暂略。
我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也很重要。
我们之前写的程序都是创建好套接字后(未经特别操作)直接使用的,此时通过默认的套接字特性进行数据通信。之前的示例较为简单,无需特别操作套接字特性,但有时的确需要更改。下标列出了一部分套接字可选项。
从表中可以看出:
可选项的读取和设置通过以下两个函数来完成:
# include
int getsockopt(int sock, int level, int optname, void* optval, socklen_t* optlen);
/*
成功时返回 0 ,失败时返回 -1
sock: 用于查看选项套接字文件描述符
level: 要查看的可选项协议层
optname: 要查看的可选项名
optval: 保存查看结果的缓冲地址值
optlen: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信
息的字节数。
*/
上述函数用于读取套接字可选项,并不难。接下来介绍更改可选项时调用的函数。
#include
int setsockopt(int sock, int level, int optname, const void* optval, socklen_t optlen);
/*
成功时返回 0 ,失败时返回 -1
sock: 用于更改选项套接字文件描述符
level: 要更改的可选项协议层
optname: 要更改的可选项名
optval: 保存更改结果的缓冲地址值
optlen: 向第四个参数传递的缓冲大小。调用函数候,该变量中保存通过第四个参数返回的可选项信
息的字节数。
*/
下面的代码可以看出 getsockopt
的使用方法。下面示例用协议层为 SOL_SOCKET
、名为 SO_TYPE
的可选项查看套接字类型(TCP 和 UDP )。
// sock_type.c
#include
#include
#include
#include
void error_handling(char* message);
int main(int argc, char* argv[]) {
int tcp_sock, udp_sock;
int sock_type;
socklen_t optlen;
int state;
optlen = sizeof(sock_type);
tcp_sock = socket(PF_INET, SOCK_STREAM, 0);
udp_sock = socket(PF_INET, SOCK_DGRAM, 0);
printf("SOCK _STREAM: %d \n", SOCK_STREAM);
printf("SOCK_DGRAM: %d \n", SOCK_DGRAM);
state = getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
if (state) error_handling("getsockopt() error!");
printf ("Socket type one: %d \n", sock_type);
state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
if (state) error_handling("getsockopt() error!");
printf ("Socket type two: %d \n", sock_type);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc sock_type.c -o sock_type
./sock_type
结果:
首先创建了一个 TCP 套接字和一个 UDP 套接字。然后通过调用 getsockopt
函数来获得当前套接字的状态。验证套接类型的 SO_TYPE
是只读可选项,因为套接字类型只能在创建时决定,以后不能再更改。
创建套接字的同时会生成 I/O 缓冲。关于 I/O 缓冲,可以去看第五章。
SO_RCVBUF
是输入缓冲大小相关可选项,SO_SNDBUF
是输出缓冲大小相关可选项。用这 2 个可选项既可以读取当前 I/O 大小,也可以进行更改。通过下列示例读取创建套接字时默认的 I/O 缓冲大小。
// get_buf.c
#include
#include
#include
#include
void error_handling(char* message);
int main(int argc, char* argv[]) {
int sock;
int snd_buf, rcv_buf, state;
socklen_t len;
sock = socket(PF_INET, SOCK_STREAM, 0);
len = sizeof(snd_buf);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, &len);
if (state) error_handling("getsockopt() error");
len = sizeof(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if (state) error_handling("getsockopt() error");
printf("Input buffer size: %d \n", rcv_buf);
printf("Output buffer size: %d \n", snd_buf);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc get_buf.c -o getbuf
./getbuf
运行结果:
可以看出本机的输入缓冲和输出缓冲大小。
下面的代码展示了,通过程序设置I/O缓冲区的大小。
// set_buf.c
#include
#include
#include
#include
void error_handling(char* message);
int main(int argc, char* argv[]) {
int sock;
int snd_buf, rcv_buf, state;
int snd_buf_mod, rcv_buf_mod;
socklen_t len;
sock = socket(PF_INET, SOCK_STREAM, 0);
len = sizeof(snd_buf);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, &len);
if (state) error_handling("getsockopt() error");
len = sizeof(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if (state) error_handling("getsockopt() error");
printf("Origin input buffer size: %d \n", rcv_buf);
printf("Origin output buffer size: %d \n", snd_buf);
// 修改
snd_buf = 1024 * 3;
rcv_buf = 1024 * 3;
state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, sizeof(snd_buf));
if (state) error_handling("getsockopt() error");
len = sizeof(rcv_buf);
state = setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, sizeof(rcv_buf));
if (state) error_handling("getsockopt() error");
// 再次读取
len = sizeof(snd_buf_mod);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf_mod, &len);
if (state) error_handling("getsockopt() error");
len = sizeof(rcv_buf_mod);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf_mod, &len);
if (state) error_handling("getsockopt() error");
printf("Modified input buffer size: %d \n", rcv_buf_mod);
printf("Modified output buffer size: %d \n", snd_buf_mod);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc get_buf.c -o setbuf
./setbuf
结果:
结果和我们想象的不同,所以设置缓冲区的大小需要谨慎。由于TCP需要实现流控制以及超时重传机制,所以会留一些缓冲空间,但是我们确实是可以通过setsockopt
设置缓冲区大小的。
在学习 SO_REUSEADDR
可选项之前,应该好好理解 Time-wait 状态。看以下代码的示例:
// reuseadr_rserver.c
#include
#include
#include
#include
#include
#include
#define TRUE 1
#define FALSE 0
void error_handling(char* message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
char message[30];
int option, str_len;
socklen_t optlen, clnt_adr_sz;
struct sockaddr_in serv_adr, clnt_adr;
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) error_handling("sock() error!");
/*
optlen = sizeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);
*/
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)))
error_handling("bind() error");
if (listen(serv_sock, 5) == -1) error_handling("listen() error");
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
while ((str_len = read(clnt_sock, message, sizeof(message))) != 0) {
write(clnt_sock, message, str_len);
write(1, message, str_len);
}
close(clnt_sock);
close(serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
这是一个回声服务器的服务端代码,可以配合第四章的 echo_client.c
使用,在这个代码中,客户端通知服务器终止程序。在客户端控制台输入 Q 可以结束程序,向服务器发送 FIN 消息并经过四次握手过程。当然,输入 CTRL+C
也会向服务器传递 FIN 信息。强制终止程序时,由操作系统关闭文件套接字,此过程相当于调用 close 函数,也会向服务器发送 FIN 消息。
上述代码中:write(1, message, str_len);
是向标准输出设备(stdout)输出数据,简单来讲,这一步就是printf。
这样看不到是什么特殊现象,考虑以下情况:
服务器端和客户端都已经建立连接的状态下,向服务器控制台输入 CTRL+C ,强制关闭服务端
如果用这种方式终止程序,如果用同一端口号再次运行服务端,就会输出 「bind() error」 消息,并且无法再次运行。但是在这种情况下,再过大约 3 分钟就可以重新运行服务端。如图所示:
关于这个Time Wait状态的作用,我们在第五章已经介绍过了。
观察以下过程:
假设图中主机 A 是服务器,因为是主机 A 向 B 发送 FIN 消息,故可想象成服务器端在控制台中输入CTRL+C 。但是问题是,套接字经过四次握手后并没有立即消除,而是要经过一段时间的 Time-wait 状态。当然,只有先断开连接的(先发送 FIN 消息的)主机才经过 Time-wait 状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait 过程时,相应端口是正在使用的状态。因此,就像之前验证过的,bind 函数调用过程中会发生错误。(主动断开连接的主机,才关注Time-wait状态。)
实际上,不论是服务端还是客户端,都要经过一段时间的 Time-wait 过程。先断开连接的套接字必然会经过 Time-wait 过程,但是由于客户端套接字的端口是任意制定的,所以无需过多关注 Time-wait 状态。
那到底为什么会有 Time-wait 状态呢?
我们在之前已经解释过了,这里再重复一次。在图中假设,主机 A 向主机 B 传输 ACK 消息(SEQ 5001, ACK 7502 )后立刻消除套接字。但是最后这条 ACK 消息在传递过程中丢失,没有传递主机 B ,这时主机 B就会试图重传。但是此时主机 A 已经是完全终止状态,因为主机 B 永远无法收到从主机 A 最后传来的ACK 消息。基于这些问题的考虑,所以要设计 Time-wait 状态。(B会一直处于超时重传的状态)
Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务起以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。
如图所示,在主机A的四次握手过程中,如果最后的数据丢失,则主机B会认为主机A未能收到自己发送的FIN消息,因此重传。这时,收到FIN消息的主机A将重启Time-wait计时器。因此,如果网络状况不理想,Time-wait状态将持续。
解决方案就是在套接字的可选项中更改 SO_REUSEADDR
的状态。适当调整该参数,可将 Time-wait
状态下的套接字端口号重新分配给新的套接字。SO_REUSEADDR
的默认值为0,这就意味着无法分配Time-wait 状态下的套接字端口号。因此需要将这个值改成 1 。具体作法已在示例 reuseadr_eserver.c
给出,只需要把注释掉的东西接解除注释即可。
optlen = sizeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);
为了防止因数据包过多而发生网络过载, Nagle
算法诞生了。它应用于 TCP 层。它是否使用会导致如图所示的差异:
图中展示了通过 Nagle
算法发送字符串 Nagle 和未使用 Nagle
算法的差别。可以得到一个结论:
只有接收到前一数据的 ACK 消息, Nagle 算法才发送下一数据。
TCP 套接字默认使用 Nagle 算法交换数据,因此最大限度的进行缓冲,直到收到 ACK 。左图也就是说一共传递 4 个数据包以传输一个字符串。从右图可以看出,发送数据包一共使用了 10 个数据包。由此可知,不使用 Nagle 算法将对网络流量产生负面影响。即使只传输一个字节的数据,其头信息都可能是几十个字节。因此,为了提高网络传输效率,必须使用 Nagle 算法。
补充:Nagle算法的核心是通过减少需要通过网络发送包的数量来提高TCP/IP传输的效率。具体来说,当有数据需要发送时,Nagle算法会将这些小数据块缓存起来,直到满足以下条件之一才会将它们打包成一个大的数据块发送出去:
但是Nagle 算法并不是什么情况下都适用,网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快。最典型的就是「传输大文数据」。将文件数据传入输出缓冲不会花太多时间,因此,不使用 Nagle 算法,也会在装满输出缓冲时传输数据包(这就是Nagle的一种情况)。这不仅不会增加数据包的数量,反而在无需等待ACK 的前提下连续传输,因此可以大大提高传输速度。所以,未准确判断数据性质时不应禁用 Nagle 算法。
刚才说过的“大文件数据”应禁用Nagle算法。换言之,如果有必要,就应禁用Nagle算法。
“Nagle算法使用与否在网络流量上差别不大,使用Nagle算法的传输速度更慢”
禁用方法非常简单。从下列代码也可看出,只需将套接字可选项TCP_NODELAY
改为**1(真)**即可。
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, sizeof(opt_val));
通过 TCP_NODELAY
的值来查看 Nagle 算法的设置状态。
opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, opt_len);
如果正在使用 Nagle 算法,那么 opt_val
值为 0,如果禁用则为 1。
但记住一点,Nagle算法是时代的产物,因为当时网络带宽有限。而当前的局域网、广域网的带宽则宽裕得多,所以目前的TCP/IP协议栈默认将Nagle算法关闭,即通过SO_NODELAY = 1
。
暂略。
错
对
错
错
利用之前学习到的内容,我们可以构建按序向第一个客户端到第一百个客户端提供服务的服务器端。当然,第一个客户端不会抱怨服务器端,但如果每个客户端的平均服务时间为0.5秒,则第100个客户端会对服务器端产生相当大的不满。
通过改进服务端,使其同时向所有发起请求的客户端提供服务,以提高平均满意度。而且,网络程序中数据通信时间比 CPU 运算时间占比更大,因此,向多个客户端提供服务是一种有效的利用 CPU 的方式。接下来讨论同时向多个客户端提供服务的并发服务器端。下面列出的是具有代表性的并发服务端的实现模型和方法:
先是第一种方法:多进程服务器
进程的定义如下:
占用内存空间的正在运行的程序
假如你下载了一个游戏到电脑上,此时的游戏不是进程,而是程序。只有当游戏被加载到主内存并进入运行状态,这是才可称为进程。同时运行多个应用程序就代表同时开启了几个进程。
CPU的核的个数与进程数:
拥有2个运算设备的CPU称作双核(Daul) CPU,拥有4个运算器的CPU称作4核( Quad )CPU。也就是说,1个CPU中可能包含多个运算设备(核)。核的个数与可同时运行的进程数相同。相反,若进程数超过核数,进程将分时使用CPU 资源。但因为CPU运转速度极快,我们会感到所有进程同时运行。当然,核数越多,这种感觉越明显。
在说进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为**「进程ID」**,其值为大于 2 的整数。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1 。接下来观察在 Linux 中运行的进程:
ps au
通过上面的命令可查看当前运行的所有进程。需要注意的是,该命令同时列出了 PID(进程ID)。参数 a 和 u列出了所有进程的详细信息。
创建进程的方式很多,此处只介绍用于创建多进程服务端的 fork 函数。
# include
pid_t fork(void);
// 成功时返回进程ID,失败时返回 -1
fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork
函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确的说是在 fork
函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork
函数的返回值加以区分。即利fork
函数的如下特点区分程序执行流程。
// fork.c
#include
#include
int gval = 10;
int main(int argc, char* argv[]) {
pid_t pid;
int lval = 20;
gval++, lval += 5;
pid = fork();
if (pid == 0) gval += 2, lval += 2; // if Child Process
else gval -= 2, lval -= 2;
if (pid == 0) printf("Child Proc: [%d, %d] \n",gval, lval);
else printf("Parent Proc: [%d, %d] \n",gval, lval);
}
编译运行:
gcc fork.c -o fork
./fork
结果:
对于单进程,if
和else
语句不会都执行,上面的代码看起来好像if
和else
都执行了,其实不然。这里其实是将代码复制了一份,复制发生点就是fork之后的位置,然后子进程和父进程拥有完全独立的内存结构,二者互不影响。
文件操作中,关闭文件和打开文件同等重要。同样,进程销毁也和进程创建同等重要。如果未认真对待进程销毁,它们将变成僵尸进程。
进程的工作完成后(执行完 main 函数中的程序后)应被销毁 (肯定就是没有销毁好),但有时这些进程将变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作「僵尸进程」,这也是给系统带来负担的原因之一。
僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被
init
接管,子进程退出后init会回收其占用的相关资源。
子进程先结束,此时就可能会变成僵尸进程。
为了防止僵尸进程产生,先解释产生僵尸进程的原因。利用如下两个示例展示调用 fork 函数产生子进程的终止方式。
exit()
函数return
语句并返回值向exit函数传递的参数值和main函数的return语句返回的值都会传递给操作系统 (注意这里:exit() 和 main函数的return都是会传给操作系统的,但是普通函数的return则是返回到调用函数的地方
)。而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。也就是说,将子进程变成僵尸进程的正是操作系统。既然如此,此僵尸进程何时被销毁呢?其实已经给出提示。
应该向创建子进程的父进程传递子进程的 exit 参数值或 return 语句的返回值。
给父进程传递这两个终止的条件
。
如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程。只有父进程主动发起请求(函数调用)的时候,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。也就是说,父母要负责收回自己生的孩子。接下类的示例将创建僵尸进程。
// zombie.c
#include
#include
int main(int argc, char* argv[]) {
pid_t pid = fork();
if (pid == 0) puts("Hi, I am a child Process");
else {
printf("Child Process ID: %d \n", pid);
sleep(30);
}
if (pid == 0) puts("End child process");
else puts("End parent process");
return 0;
}
编译运行:
gcc zombie.c -o zombie
./zombie
结果:
逻辑如下:
运行,父进程会打印子进程的ID,此时为7145,然后子进程不需要等待30秒,直接调用return结束了,打印End child Process
,但是此时使用ps au
,发现该ID仍然存在,此时子进程就是僵尸进程,直到30秒之后,父进程调用return结束,才会回收子进程,此时才会释放僵尸进程。
使用
./zombie &
可以后台运行,不需要重新打开一个命令窗口。
如前所述,为了销毁子进程,父进程应该主动请求获取子进程的返回值。下面是发起请求的具体方法。有两种,下面的函数是其中一种。
#include
pid_t wait(int* statloc);
/*
成功时返回终止的子进程 ID ,失败时返回 -1
*/
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值( exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指内存空间。但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离。
(说白了参数指向的单元还有其他东西,不止有返回值 (比如是否正确返回之类的信息),所以得用宏来分离出返回值)。
也就是说,向 wait 函数传递变量 status 的地址时,调用 wait 函数后应编写如下代码:
if (WIFEXITED(status) { // 是正常终止的吗
puts("Normal termination"); // 是正常终止的,说明一下
printf("Child pass num: %d", WEXITSTATUS(status));
}
根据上述内容编写示例,此示例中不会再让子进程变成僵尸进程。
// wait.c
#include
#include
#include
#include
int main(int argc, char* argv[]) {
int status;
pid_t pid = fork();
if (pid == 0) return 3; // 子进程直接返回
else {
printf("Child PID: %d \n", pid);
pid = fork(); // 再开一个子进程
if (pid == 0) exit(7); // 使用exit来结束
else {
printf("Child PID: %d \n", pid);
wait(&status); //之前终止的子进程相关信息将被保存到 status 中,同时相关子进程被完全销毁
if (WIFEXITED(status)) printf("Child send one : %d \n", WEXITSTATUS(status));
wait(&status); //因为之前创建了两个进程,所以再次调用 wait 函数和宏
if (WIFEXITED(status)) printf("Child send two : %d \n", WEXITSTATUS(status));
sleep(30);
}
}
return 0;
}
编译运行:
gcc wait.c -o wait
./wait
运行结果:
可以看到,即使父进程还没有结束,此时子进程已经被销毁了。
这就是通过 wait 函数消灭僵尸进程的方法,调用 wait 函数时,如果没有已经终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此要谨慎调用该函数。(谨慎调用)
wait
函数会引起程序阻塞,还可以考虑调用 waitpid
函数。这是防止僵尸进程的第二种方法,也是防止阻塞的方法。
#include
pid_t waitpid(pid_t pid, int* statloc, int options);
/*
成功时返回终止的子进程ID 或 0 ,失败时返回 -1
pid: 等待终止的目标子进程的ID,若传 -1,则与 wait 函数相同,可以等待任意子进程终止
statloc: 与 wait 函数的 statloc 参数具有相同含义
options: 传递头文件 sys/wait.h 声明的常量 WNOHANG ,即使没有终止的子进程也不会进入阻塞状态,而是返回 0 退出函数。
*/
以下是waitpid
的示例。
// waitpid.c
#include
#include
#include
#include
int main(int argc, char* argv[]) {
int status;
pid_t pid = fork();
if (pid == 0) { //用 sleep 推迟子进程的执行
sleep(15);
return 24;
}
else {
while (!waitpid(-1, &status, WNOHANG)) {
sleep(3);
puts("sleep 3sec.");
}
if (WIFEXITED(status)) printf("Child send %d \n", WEXITSTATUS(status));
}
return 0;
}
编译运行:
gcc waitpid.c -o waitpid
./waitpid
运行结果:
可以看出来,在 while 循环中正好执行了 5 次。这也证明了 waitpid
函数并没有阻塞。
如果把waitpid
替换成wait
,结果如下:
我们已经知道了进程的创建及销毁的办法,但是还有一个问题没有解决。
子进程究竟何时终止?调用 waitpid 函数后要无休止的等待吗?
父进程往往与子进程一样繁忙,因此不能只调用waitpid函数以等待子进程终止。接下来讨论解决方案。
子进程终止的识别主体是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,将有助于构建高效的程序。
”嘿,父进程!你创建的子进程终止了!“
此时父进程将暂时放下工作,处理子进程终止相关事宜。这是不是既合理又很酷的想法呢?为了实现该想法,我们引入信号处理**(Signal Handling)机制。此处的“信号”是在特定事件发生时由操作系统向进程发送的消息**。另外,为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”。
下列进程和操作系统的对话可以帮助理解信号处理的编写,其中包含了所有信号处理相关内容。
进程:操作系统,如果我之前创建的子进程终止,就帮我调用
zombie_handler
函数。
操作系统:好的,如果你的子进程终止,我就帮你调用zombie_handler
函数,你先把要函数要执行的语句写好。
上述对话中进程所讲的相当于“注册信号”过程,即进程发现自己的子进程结束时,请求操作系统调用特定函数。该请求通过如下函数调用完成(因此称此函数为信号注册函数)。
#include
void (*signal(int signo, void (*func)(int)))(int);
这里涉及到函数指针的知识,之前没接触过,这里补充:
http://c.biancheng.net/view/228.html
举一个例子来用一下函数指针:
# include
int max(int, int); //函数声明
int main(void)
{
int(*p)(int, int); //定义一个函数指针
int a, b, c;
p = max; //把函数max赋给指针变量p, 使p指向max函数
printf("please enter a and b:");
scanf("%d%d", &a, &b);
c = (*p)(a, b); //通过函数指针调用max函数
printf("a = %d\nb = %d\nmax = %d\n", a, b, c);
return 0;
}
int max(int x, int y) //定义max函数
{
return x > y ? x : y;
}
上述例子中,p就是一个函数指针。我们通常定义的函数名本质上也就是一个函数指针,比如max就是指向该函数的地址,所以这里可以令p = max
。
其实c语言的函数名本质上就是一个函数指针,也就是说,可以通过(*函数名) (实参)
的方式来调用函数。
上述的信号注册函数:以后忘了可以取看看这个文章。
可以这么解读,对signal(int signo, void (*func)(int))
而言,signal是函数名,第一个参数是整型,第二个参数而类型数函数指针,该指针指向的函数的第一个参数是int,返回类型为void的函数。
再看void (*signal(int signo, void (*func)(int)))(int);
,其实signal的返回值类型也是一个函数指针,这个函数指针指向的是一个参数为int,返回值类型是void的函数。
其实这里就是返回值类型为函数指针的一个函数。
调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在 signal 函数中注册
的部分特殊情况和对应的函数。
接下来编写signal函数的语句完成如下请求:
”子进程终止则调用mychild函数“
此时 mychild 函数的参数应为 int ,返回值类型应为 void 。只有这样才能成为 signal 函数的第二个参数。另外,常数 SIGCHLD 定义了子进程终止的情况,应成为 signal 函数的第一个参数。也就是说,signal 函数调用语句如下:
signal(SIGCHLD , mychild);
接下来编写 signal 函数的调用语句,分别完成如下两个请求:
- 已到通过 alarm 函数注册时间,请调用 timeout 函数
- 输入 ctrl+c 时调用 keycontrol 函数
代表这 2 种情况的常数分别为 SIGALRM
和 SIGINT
,因此按如下方式调用 signal 函数。
signal(SIGALRM , timeout);
signal(SIGINT , keycontrol);
以上就是信号注册过程。注册好信号之后,发生注册信号时(注册的情况发生时),操作系统将调用该信号对应的函数。先介绍 alarm 函数。
#include
unsigned int alarm(unsigned int seconds);
// 返回0或以秒为单位的距 SIGALRM 信号发生所剩时间
如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果通过改函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。
接下来根据示例,巩固之前的内容:
// signal.c
#include
#include
#include
void timeout(int sig) {
if (sig == SIGALRM) puts("Time out!");
alarm(2);
}
void keycontrol(int sig) {
if (sig == SIGINT) puts("CTRL + C pressed");
}
int main(int argc, char* argv[]) {
int i;
signal(SIGALRM, timeout); // 信号注册
signal(SIGINT, keycontrol);
alarm(2);
for (int i = 0; i < 3; i++) {
puts("wait...");
sleep(100);
}
return 0;
}
为了查看信号产生和信号处理器的执行并提供每次100秒、共3次的等待时间,在循环中调用sleep函数。也就是说,再过300秒、约5分钟后终止程序,这是相当长的一段时间,但实际执行时只需不到10秒。关于其原因稍后再解释。
编译运行:
gcc signal.c -o signal
./signal
运行结果:
以上是不执行任何操作的结果,如果按下CTRL + C
可以直接调用keycontrol
函数而不用等到alarm
执行完毕。
以上结果表明sleep
函数并没有真正执行100s,而是被唤醒了:
“发生信号时将唤醒由于调用sleep函数而进入阻塞状态的进程。”
调用函数的主题的确是操作系统,但是进程处于睡眠状态时无法调用函数,因此,产生信号时,为了调用信号处理器,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到(甚至连一次alarm的定时都没有走完就结束了)。
具体来说: for循环一共有3次,根据第二个运行结果,首先wait 2秒,然后输出Time out!接下来继续wait,如果在等待了1s之后,按下CTRL+C
,那么第二次sleep也会被打断,然后进入wait。此时大概还剩下1秒,等待1秒之后,最后输出Time out!即可。
前面所学的内容可以防止僵尸进程,还有一个函数,叫做 sigaction
函数,他类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为:
signal 函数在 Unix 系列的不同操作系统可能存在区别,但 sigaction 函数完全相同
实际上现在很少用 signal 函数编写程序,它只是为了保持对旧程序的兼容,下面介绍 sigaction
函数,只讲解可以替换 signal 函数的功能。
#include
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
/*
成功时返回 0 ,失败时返回 -1
act: 对于第一个参数的信号处理函数(信号处理器)信息。
oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0
*/
声明并初始化 sigaction 结构体变量以调用上述函数,该结构体定义如下:
struct sigaction
{
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
};
此结构体的成员 sa_handler 保存信号处理的函数指针值(地址值)。sa_mask
和 sa_flags
的所有位初始化 0 即可。这 2 个成员用于指定信号相关的选项和特性,而我们的目的主要是防止产生僵尸进程,故省略。
下面的例子展示了sigaction
函数的使用方法:
// sigaction.c
#include
#include
#include
void timeout(int sig) {
if (sig == SIGALRM) puts("Time out!");
alarm(5);
}
int main(int argc, char* argv[]) {
int i;
struct sigaction act;
act.sa_handler = timeout; // 保存函数指针
sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0
act.sa_flags = 0; //sa_flags 同样初始化成 0
sigaction(SIGALRM, &act, 0); //注册 SIGALRM 信号的处理器。
alarm(2); //2 秒后发生 SIGALRM 信号
for (int i = 0; i < 3; i++) {
puts("wait...");
sleep(100);
}
return 0;
}
编译运行:
gcc sigaction.c -o sigaction
./sigaction
结果:
下面利用子进程终止时产生 SIGCHLD 信号这一点,来用信号处理来消灭僵尸进程。看以下代码:(首先独立编写)
// 自己编写的代码
#include
#include
#include
#include
#include
void mychild(int sig) {
if (sig == SIGCHLD)
printf("子进程终止 \n");
}
int main(int argc, char* argv[]) {
struct sigaction act;
act.sa_handler = mychild; // 保存函数指针
sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0
act.sa_flags = 0; //sa_flags 同样初始化成 0
sigaction(SIGCHLD, &act, 0); //注册 SIGALRM 信号的处理器。
pid_t pid = fork();
if (pid == 0) return 5; // 如果是子进程,直接返回5
else {
// 由于父进程也是很繁忙的,所以不可能一直调用waitpid来监听子进程的情况
printf("Child Process ID: %d \n", pid); // 打印子进程的ID
sleep(3);
}
}
编译运行:
gcc remove_zomebie.c -o zombie
./zombie
结果:
如图所示,子进程可以自己终止,而且父进程的sleep函数也没啥作用了,直接唤醒了。
书上的代码:
#include
#include
#include
#include
#include
void read_childproc (int sig) {
int status;
pid_t id = waitpid(-1, &status, WNOHANG);
if (WIFEXITED(status)) {
printf("Removed proc id : %d \n", id);
printf("Child send: %d \n", WEXITSTATUS(status));
}
}
int main(int argc, char* argv[]) {
pid_t pid;
struct sigaction act;
act.sa_handler = read_childproc; // 保存函数指针
sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0
act.sa_flags = 0; //sa_flags 同样初始化成 0
sigaction(SIGCHLD, &act, 0); //注册 SIGALRM 信号的处理器
pid = fork();
if (pid == 0) {
puts("Hi! I'm child process one");
sleep(10);
return 12;
}
else {
printf("Child proc id: %d \n", pid);
pid = fork(); //创建第二个进程
if (pid == 0) {
puts("Hi! I'm child process two");
sleep(8);
exit(24);
}
else {
int i;
printf("Child proc id: %d \n", pid);
for (i = 0; i < 5; i++) {
puts("wait...");
sleep(5);
}
}
}
return 0;
}
运行结果:
这里主要解析以下运行的结果:首先第一个wait出来,等5秒中,然后出现第二个wait,第二个wait过3秒之后,第二个进程结束,打印结果,然后这次wait就被打断了(sleep被唤醒)。然后开始第三个wait,第三个wait过2s之后,第一个进程的时间也到了,然后就打印结果。此时第3个wait也就结束了。最后还有两个wait,10秒,由于没什么操作,走完之后程序就结束了。程序实际执行的时间是小于25秒的。
可以看出,子进程并未变成僵尸进程,而是正常终止了。接下来利用进程相关知识编写服务器端。
我们已做好了利用fork
函数编写并发服务器的准备,现在可以开始编写像样的服务器端了。
之前的回声服务器每次只能同时向 1 个客户端提供服务。因此,需要扩展回声服务器,使其可以同时向多个客户端提供服务。下图是基于多进程的回声服务器的模型。
从图中可以看出,每当有客户端请求时(连接请求),回声服务器都创建子进程以提供服务。如果请求的客户端有 5 个,则将创建 5 个子进程来提供服务,为了完成这些任务,需要经过如下过程:
以下实现的是多进程的回声服务器:
// echo_mpserv.c
#include
#include
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
void read_childproc(int sig);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
pid_t pid;
struct sigaction act;
socklen_t adr_sz;
int str_len, state;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
// 下面是为防止产生僵尸进程而写的代码,使用信号处理的方法处理僵尸进程
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
// 套接字创建
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
// 分配端口与监听
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
while(1) {
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1)
continue;
else
puts("new client connect...");
pid = fork(); // 这里调用fork()函数之后,父子进程都带有49行生成的套接字(受理客户端连接请求时创建的)文件描述符
if (pid == -1) { // 创建失败
close(clnt_sock);
continue;
}
if (pid == 0) { // 子进程运行区域
close(serv_sock); // 这里关闭服务器套接字的原因后面讨论,因为子进程也复制了这个玩意儿
while ((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len); // 原地发送给客户端
close(clnt_sock);
puts("client disconnected...");
return 0;
}
else
close(clnt_sock); // 通过accept生成的文件描述符已经复制给子进程了,所以这里就需要删除自己的套接字
}
close(serv_sock);
return 0;
}
void read_childproc(int sig) {
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("removed proc id: %d \n", pid);
}
编译运行:
gcc echo_mpserv -o mpserv
./mpserv 9190
示例echo_mpserv.c
中给出了通过fork
函数复制文件描述符的过程。父进程将2个套接字(一个是服务器端套接字,另一个是与客户端连接的套接字)文件描述符复制给子进程。
“只复制文件描述符吗?是否也复制了套接字呢?”
文件描述符的实际复制多少有些难以理解。调用fork函数时复制父进程的所有资源,有些人可能认为也会同时复制套接字。但套接字并非进程所有——从严格意义上说,套接字属于操作系统——只是进程拥有代表相应套接字的文件描述符。也不一定非要这样理解,仅因为如下原因,复制套接字也并不合理。
echo_mpserv.c
中的fork函数调用过程如图所示,调用fork函数之后,2个文件描述符指向同一个套接字。
如上图所示,1个套接字中存在2个文件描述符时,只有2个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的连接状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法完全销毁套接字(服务器端套接字同样如此)。因此,调用fork函数后,要将无关的套接字文件描述符关掉,如下图所示。
所以在上述的代码中,调用了fork函数之后,进入子进程之后,需要把子进程中指向服务器端的文件描述符删掉;在父进程中,需要把指向客户端的文件描述符删除。
我们已经实现的回声客户端的数据回声方式如下:
向服务器传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才
能传输下一批数据。
传输数据后要等待服务器端返回的数据,因为程序代码中重复调用了 read 和 write 函数。只能这么写的原因之一是,程序在 1 个进程中运行,现在可以创建多个进程,因此可以分割数据收发过程。默认分割过程如下图所示:
从图中可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。
分割 I/O 程序的另外一个好处是,可以提高频繁交换数据的程序性能,如下图所示:
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
void read_routine(int sock, char* buf);
void write_routine(int sock, char* buf);
int main(int argc, char* argv[]) {
int sock;
pid_t pid;
char buf[BUF_SIZE];
struct sockaddr_in serv_adr;
if (argc != 3) {
printf("Usage %s : '\n' " , argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1) error_handling("soch() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");
pid = fork();
if (pid == 0)
write_routine(sock, buf);
else
read_routine(sock, buf);
close(sock);
return 0;
}
void read_routine(int sock, char* buf) {
while(1) {
int str_len = read(sock, buf, BUF_SIZE);
if (str_len == 0) return;
buf[str_len] = 0;
printf("Message from server: %s", buf);
}
}
void write_routine(int sock, char* buf) {
while (1) {
fgets(buf, BUF_SIZE, stdin);
if (!strcmp(buf, "q\n") || !strcmp(buf, "Q\n")) {
shutdown(sock, SHUT_WR);
return;
}
write(sock, buf, strlen(buf));
}
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc echo_mpclient.c -o eclient
./eclient 127.0.0.1 9190
结果:
可以看出,基本和以前的一样,但是里面的内部结构却发生了很大的变化。
第10章讲解了如何创建进程,本章将讨论创建的2个进程之间交换数据的方法。这与构建服务器端并无直接关系,但可能有助于构建多种类型服务器端,以及更好地理解操作系统。
进程间通信(Inter Process Communication)意味着两个不同进程间可以交换数据,为了完成这一点,操作系统中应提供两个进程可以同时访问的内存空间。(公共数据空间)
正如第10章所讲,进程具有完全独立的内存结构。就连通过fork函数创建的子进程也不会与父进程共享内存空间。因此,进程间通信只能通过其他特殊方法完成。
下图是基于管道(PIPE)的进程间通信的模型:
可以看出,为了完成进程间通信,需要创建进程。管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是 fork 函数的复制对象)。所以,两个进程通过操作系统提供的内存空间进行通信。下面是创建管道的函数。
#include
int pipe(int filedes[2]);
/*
成功时返回 0 ,失败时返回 -1
filedes[0]: 通过管道接收数据时使用的文件描述符,即管道出口
filedes[1]: 通过管道传输数据时使用的文件描述符,即管道入口
*/
父进程创建函数时将创建管道,同时获取对应于出入口的文件描述符,此时父进程可以读写同一管道。但父进程的目的是与子进程进行数据交换,因此需要将入口或出口中的 1 个文件描述符传递给子进程。下面的例子是关于该函数的使用方法:
#include
#include
#define BUF_SIZE 30
int main(int argc, char* argv[]) {
pid_t pid;
int fds[2];
char str[] = "How are you?";
char buf[BUF_SIZE];
// 调用 pipe 函数创建管道,fds 数组中保存用于 I/O 的文件描述符
pipe(fds);
pid = fork(); // 子进程将同时拥有创建管道获取的2个文件描述符,复制的并非管道,而是文件描述符
if (pid == 0) write(fds[1], str, sizeof(str));
else {
read(fds[0], buf, BUF_SIZE);
puts(buf);
}
return 0;
}
编译运行:
gcc pipe1.c -o pipe1
./pipe1
结果:
Who are you?
注意:调用fork函数的时候,子进程将同时拥有通过第12行函数调用获取的2个文件描述符。注意!复制的并非管道,而是用于管道I/O的文件描述符。至此,父子进程同时拥有I/O文件描述符 (所以是可以进行双向通信的)。如下图所示:
下面介绍通过管道的双向通信。
下面创建2个进程通过1个管道进行双向数据交换的示例,其通信方式如下图所示:
采用这种模型要格外注意,这里先给出示例:
#include
#include
#define BUF_SIZE 30
int main(int argc, char* argv[]) {
int fds[2]; // 管道I/O的文件描述符
char str1[] = "How are you?";
char str2[] = "I'm fine, thanks!";
char buf[BUF_SIZE];
pid_t pid;
pipe(fds);
pid = fork();
if (pid == 0) {
write(fds[1], str1, sizeof(str1));
sleep(2);
read(fds[0], buf, BUF_SIZE);
printf("Child proc output: %s \n", buf);
}
else {
read(fds[0], buf, BUF_SIZE);
printf("Parent proc output: %s \n", buf);
write(fds[1], str2, sizeof(str2));
sleep(3);
}
return 0;
}
编译运行:
gcc pipe2.c -o pipe2
./pipe2
sleep
函数。关于这一点稍后再讨论。sleep
函数父进程先终止时会弹出命令提示符。这时子进程仍在工作,故不会产生问题。这条语句主要是为了防止子进程终止前弹出命令提示符(故可删除)。可以注释掉代码后再运行。如果将子进程中的上来sleep
函数注释,将会产生问题:
“向管道传递数据时,先读的进程会把数据取走。”
简言之,数据进入管道后成为无主数据。也就是通过read函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因此,注释第18行将产生问题。在第19行,子进程将读回自己在第17行向管道发送的数据 (自己会把自己的数据读了)。结果,父进程调用read函数后将无限期等待数据进入管道。
针对这个问题,一个管道是解决不了的,所以需要创建两个管道来解决,各自负责不同的数据流动。
下面采用上述模型改进 pipe2.c
。
pipe(fds1), pipe(fds2);
pid = fork();
if (pid == 0) {
write(fds1[1], str1, sizeof(str1));
read(fds2[0], buf, BUF_SIZE);
printf("Child proc output: %s \n", buf);
}
else {
read(fds1[0], buf, BUF_SIZE);
printf("Parent proc output: %s \n", buf);
write(fds2[1], str2, sizeof(str2));
sleep(3);
}
return 0;
}
上面通过创建两个管道实现了功能,父进程的sleep
没啥用,就是为了防止弹窗结束的。
这里要注意,read
函数是一个阻塞函数,其实子进程在执行完write
之后也会立马执行read
,但是没有数据过来,所以子进程处于阻塞状态,当有数据过来的时候,再调用read
读取数据。
下面对第 10 章的 echo_mpserv.c
进行改进,添加一个功能:
将回声客户端传输的字符串按序保存到文件中
#include
#include
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
void read_childproc(int sig);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
int fds[2];
pid_t pid;
struct sigaction act;
socklen_t adr_sz;
int str_len, state;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
// 下面是为防止产生僵尸进程而写的代码,使用信号处理的方法处理僵尸进程
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) error_handling("socket error()");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
// 分配端口与监听
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
pid = fork(); //首先创建一个子进程来保存文件
if (pid == 0) {
FILE* fp = fopen("echomsg.txt", "wt");
char msgbuf[BUF_SIZE];
int i, len;
for (i = 0; i < 10; i++) {
len = read(fds[0], msgbuf, BUF_SIZE);
fwrite((void*)msgbuf, 1, len, fp);
}
fclose(fp);
return 0;
}
while (1) {
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1) continue; // 进程有问题就继续接受下一个进程的到来
else puts("new client connected...");
pid = fork();
if(pid == 0) {
close(serv_sock);
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0) {
write(clnt_sock, buf, str_len);
write(fds[1], buf, str_len); // 管道的输入,通过fds[1]传递字符信息
}
close (clnt_sock);
puts("client disconnected...");
exit(1);
}
else close(clnt_sock);
}
close(serv_sock);
return 0;
}
void read_childproc(int sig) {
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("removed proc id : %d \n", pid);
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc echo_storeserv.c -o serv
./serv 9190
结果:
此服务端配合第 10 章的客户端 echo_mpclient.c
使用,运行结果如下图:
我这里是是一个命令行整完,再敲的另一个命令行,所以顺序是错开的。
从图上可以看出,服务端已经生成了文件,把客户端的消息保存可下来,只保存了10次消息。
进程可能不会实际用到,但是这是学习多线程的基础。
本章将讨论并发服务器的第二种实现方法——基于I/O复用(Multi-plexing)的服务器端构建。虽然通过本章多学习一种服务器端实现方法非常重要,但更重要的是理解每种技术的优缺点。如果能掌握每种技术的优劣,就可以根据特定目标灵活应用不同模型,而不是仅关注功能实现。
为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用一种方案,但并非十全十美,因为创建进程时需要付出极大代价。这需要大量的运算和内存空间由于每个进程都具有独立的内存空间,所以相互间的数据交换也要求采用相对复杂的方法(IP属于相对复杂的通信方法)。(十分耗费空间)
I/O 复用就可以实现不通过多进程的方式同时向多个客户端提供服务。但是也不可过于依赖该模型,该方案并不适用于所有情况,应当根据目标服务器端的特点采用不同实现方法。下面先理解**“复用”(Multiplexing)**的意义。
「复用」 在电子及通信工程领域很常见,向这些领域的专家询问其概念,可能会得到如下答复:
在 1 个通信频道中传递多个数据(信号)的技术
「复用」 的含义:
为了提高物理设备的效率,只用最少的物理要素传递最多数据时使用的技术
上述两种方法的内容完全一致。可以用纸电话模型做一个类比:
上图是一个纸杯电话系统,为了使得三人同时通话,说话时要同时对着两个纸杯,接听时也需要耳朵同时对准两个纸杯。为了完成 3 人通话,可以进行如下图的改进:
复用技术的优点:
即使减少了连线和纸杯的量仍然可以进行三人同时说话,但是如果碰到以下情况:
实际上,因为是在进行对话,所以很少发生同时说话的情况。也就是说,上述系统采用的是「时分复用」技术。因为说话人声频率不同,即使在同时说话也能进行一定程度上的区分(杂音也随之增多)。因此,也可以说是「频分复用」技术。
纸杯电话系统引入复用技术后,可以减少纸杯数和连线长度。同样,服务器端引入复用技术可以减少所需进程数。为便于比较,先给出第10章的多进程服务器端模型,如图所示:
下图是引入复用技术之后的模型:
可以看到,无论连接多少客户端,提供服务的进程只有1个。
运用select
函数是最具代表性的实现复用服务器端方法。
使用 select 函数时可以将多个文件描述符集中到一起统一监视,项目如下:
术语:「事件」。当发生监视项对应情况时,称「发生了事件」。
select函数的使用方法与一般函数区别较大,更准确地说,它很难使用。但为了实现I/O复用服务器端,我们应掌握select函数,并运用到套接字编程中。认为“select函数是I/O复用的全部内容”也并不为过。接下来介绍select函数的调用方法和顺序,如下图所示:
上图给出了从调用select函数到获取结果所经过程。可以看到,调用select函数前需要一些准备工作,调用后还需查看结果。接下来按照上述顺序逐一讲解。
利用select函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中到一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述3种监视项分成3类。
利用 fd_set
数组变量执行此操作,如图所示,该数组是存有0和1的位数组。
上图中最左端的位表示文件描述符0(所在位置)。如果该位设置为1,则表示该文件描述符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是文件描述符1和3。在fd_set
变量中注册或更改值都由下列宏完成。
FD_ZERO(fd_set *fdset)
: 将 fd_set 变量所指的位全部初始化成0FD_SET(int fd,fd_set *fdset)
:在参数 fdset 指向的变量中注册文件描述符 fd 的信息FD_SLR(int fd,fd_set *fdset)
:从参数 fdset 指向的变量中清除文件描述符 fd 的信息FD_ISSET(int fd,fd_set *fdset)
:若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回「真」上述函数中,FD_ISSET
用于验证 select
函数的调用结果,通过下图解释这些函数的功能:
下面是select函数的定义:
#include
#include
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
/*
成功时返回大于0的值,失败时返回-1.
maxfd 监视对象文件描述符数量
readset 将所有关注“是否存在待读取数据”的文件描述符注册到fd_set型变量,并传递其地址值
writeset 将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set型变量,并传递其地址值
exceptset 将所有关注“是否发生异常”的文件描述符注册到fd_set型变量,并传递其地址值
timeout 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息
返回值:发生错误时返回 -1,超时时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数
*/
如上所述,select函数用来验证3种监视项的变化情况。根据监视项声明3个fd_set型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select函数前)需要决定下面2件事。
- 文件描述符的监视(检查)范围是?
- 如何设定select函数的超时时间?
第一,文件描述符的监视范围与select函数的第一个参数有关。实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。加1是因为文件描述符的值从0开始。
第二,select函数的超时时间与select函数的最后一个参数有关,其中timeval结构体定义如下:
struct timeval {
long tv_sec; // seconds
long tv_usec; // microseconds
}
本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填人tv_sec成员,将毫秒数填入tv_usec成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select函数返回0。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递NULL参数。
select函数返回正整数时,怎样获知哪些文件描述符发生了变化?向select函数的第二到第p个参数传递的fd_set变量中将产生如图所示变化,获知过程并不难。
fd中为1的是监视对象
,由图可知,select 函数调用完成候,向其传递的 fd_set
变量将发生变化。原来为 1 的所有位将变成 0,
但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化。
下面是一个例子,便于理解之前的内容:
#include
#include
#include
#include
#include
#define BUF_SIZE 30
int main(int argc, char* argv[]) {
fd_set reads, temps;
int result, str_len;
char buf[BUF_SIZE];
struct timeval timeout;
FD_ZERO(&reads);
FD_SET(0, &reads); // 将文件描述符为0对应的位设置为1。换言之,需要监视标准输入的变化
/*
timeout.tv_sec = 4;
timeout.tv_usec = 5000;
*/
while(1) {
temps = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
result = select(1, &temps, 0, 0, &timeout);
if (result == -1) {
puts("select() error!");
break;
}
else if (result == 0) puts("Time-out!");
else {
if (FD_ISSET(0, &temps)) {
str_len = read(0, buf, BUF_SIZE);
buf[str_len] = 0;
printf("message from console: %s", buf);
}
}
}
return 0;
}
以下是对代码的一些说明:(这个行没有啥意义,就是为了对代码进行说明)
第14、15行: 看似复杂,实则简单。首先在第14行初始化fd_set变量,第15行将文件描述符0对应的位设置为1。换言之,需要监视标准输入的变化。
第24行: 将准备好的fd_set变量reads的内容复制到temps变量,因为之前讲过,调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须经过这种复制过程,这是使用select函数的通用方法。
上面的被注释的代码:请观察被注释的代码,这是为了设置select函数的超时而添加的。但不能在此时设置超时。因为调用select函数后,结构体timeval的成员tv_sec和tv_usec的值将被替换为超时前剩余时间。因此,调用select函数前,每次都需要初始化timeval结构体变量。(每一次都要初始化,所以将初始化放在了循环的内部,每一次select之前)
调用select函数。如果有控制台输入数据,则返回大于0的整数;如果没有输入数据而引发超时,则返回0。(这里需要注意的是:我们监听的是文件描述符0,但是但是,如果该事件发生了,返回的以及准备好的文件描述符的数量?(其实这里暂时也还不明白,反正来说返回的不是0))然后此时可以调用FD_ISSET
宏来检查具体哪些文件描述符已经准备好读写。FD_ISSET
宏的第一个参数是文件描述符,第二个参数是一个文件描述符集合,如果该文件描述符在集合中已经准备好读写,那么该宏返回true,否则返回false。
暂时先按照这样的流程来理解,就是返回值那里的问题,最后记得FD_ISSET
使用read从控制台读取输入,第一个参数设置为0。
编译运行:
gcc select.c -o select
./select
结果:
可以看出,如果运行后在标准输入流输入数据,就会在标准输出流输出数据,但是如果 5 秒没有输入数据,就提示超时。
下面是使用select函数实现服务端的的方法:
#include
#include
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
struct timeval timeout;
fd_set reads, cpy_reads;
socklen_t adr_sz;
int fd_max, str_len, fd_num, i;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) error_handling("socket error()");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
FD_ZERO(&reads);
FD_SET(serv_sock, &reads);
fd_max = serv_sock;
while (1) {
cpy_reads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
if ((fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout)) == -1) // 监视失败
break;
if (fd_num == 0) continue; // 超时
for (i = 0; i < fd_max + 1; i++) {
if (FD_ISSET(i, &cpy_reads)) {
if (i == serv_sock) { // Connect request!
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock,(struct sockaddr*)&serv_adr, &adr_sz);
FD_SET(clnt_sock, &reads);
if (fd_max < clnt_sock) fd_max = clnt_sock;
printf("connected client: %d \n", clnt_sock);
}
else { // read message!
str_len = read(i, buf, BUF_SIZE);
if (str_len == 0) { // close request!
FD_CLR(i, &reads);
close(i);
printf("closed client: %d \n", i);
}
else {
write(i, buf, str_len); // echo!
}
}
}
}
}
close (serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
代码中有许多需要进行解释和理解的地方,总体来将,这个代码还是比较复杂的,下面是关于上述代码的解释:
首先:第一次注册:向要传到select
函数第二个参数的fd_set
变量reads
注册服务器端套接字。这样,接收数据情况的监视对象就包含了服务器端套接字。客户端的连接请求同样通过传输数据完成。因此,服务器端套接字中有接收的数据,就意味着有新的连接请求。
其次:在while无限循环中调用select函数。select函数的第三和第四个参数为空。只需根据监视目的传递必要的参数。其中cpy_reads是会变化的,因为每次执行完select之后,fd_set中所有都要置0,除了发生事件的文件描述符保持1。而reads存放的最初的,所以在每次while开始之前都要给cpy_reads更新一下。
然后:select函数返回大于等于1的值时执行的循环。第56行调用FD_ISSET
函数,查找发生状态变化的(有接收数据的套接字的)文件描述符。
再然后:发生状态变化时,首先验证服务器端套接字中是否有变化。如果是服务器端套接字的变化,将受理连接请求。特别需要注意的是,第63行在fd_set
变量reads
中注册了与客户端连接的套接字文件描述符。(新来的客户端套接字的描述符需要加入监听)
再然后:发生变化的套接字并非服务器端套接字时,即有要接受的数据时执行else语句。但此时需要确认接收的数据是字符串还是代表断开连接的EOF。接收的数据为EOF时需要关闭套接字,并从reads中删除相应信息。
最后:接收的数据为字符串时,执行回声服务。
编译运行:
gcc echo_selectserv.c -o selserv
./selserv 9190
结果:
从结果可以看到,我们使用了select也达到了多进程回声服务器一样的功能,这就是select的作用。
暂略。
之前的示例中,基于Linux 的使用read&write函数完成数据IO,基于Windows的则使用send & recv函数。原因已经在第1章进行了充分阐述。本章的Linux示例也将使用send & recv函数,并讲解其与read & write函数相比的优点所在。还将介绍几种其他的IO函数。
首先看send函数的定义:
#include
ssize_t send(int sockfd, const void * buf, size_t nbytes, int flags);
/*
成功时返回发送的字节数,失败时返回 -1
sockfd: 表示与数据传输对象的连接的套接字和文件描述符
buf: 保存带传输数据的缓冲地址值
nbytes: 待传输字节数
flags: 传输数据时指定的可选项信息
*/
下面是recv函数的定义:
#include
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
/*
成功时返回接收的字节数(收到 EOF 返回 0),失败时返回 -1
sockfd: 表示数据接受对象的连接的套接字文件描述符
buf: 保存接受数据的缓冲地址值
nbytes: 可接收的最大字节数
flags: 接收数据时指定的可选项参数
*/
send 和 recv 函数都是最后一个参数是收发数据的可选项,该选项可以用**位或(bit OR)运算符(| 运算符)**同时传递多个信息。下表展示了send&recv函数的可选项及含义:
MSG OOB可选项用于发送“带外数据”紧急消息。假设医院里有很多病人在等待看病,此时若有急诊患者该怎么办?
”当然是优先处理“
下面通过示例学习MSG_OOB:
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
int main(int argc, char* argv[]) {
int sock;
struct sockaddr_in recv_adr;
sock = socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1) error_handling("sock() error");
memset(&recv_adr, 0, sizeof(recv_adr));
recv_adr.sin_family = AF_INET;
recv_adr.sin_addr.s_addr = inet_addr(argv[1]);
recv_adr.sin_port = htons(atoi(argv[2]));
if (connect(sock, (struct sockaddr*)&recv_adr, sizeof(recv_adr)) == -1)
error_handling("connect() error");
write(sock, "123", strlen("123"));
send(sock, "4", strlen("4"), MSG_OOB);
write(sock, "567", strlen("567"));
send(sock, "890", strlen("890"), MSG_OOB);
close(sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
从上述示例可以看出,紧急消息的传输比即将介绍的接收过程要简单,只需在调用send函数时指定MSG_OOB可选项。接收紧急消息的过程要相对复杂一些。
下面是紧急消息的接收:
#include
#include
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
void urg_handler(int signo);
int acpt_sock;
int recv_sock;
int main(int argc, char* argv[]) {
struct sockaddr_in recv_adr, serv_adr;
int str_len, state;
socklen_t serv_adr_sz;
struct sigaction act;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
acpt_sock = socket(PF_INET, SOCK_STREAM, 0);
if (recv_sock == -1) error_handling("sock() error");
memset(&recv_adr, 0, sizeof(recv_adr));
recv_adr.sin_family = AF_INET;
recv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
recv_adr.sin_port = htons(atoi(argv[1]));
act.sa_handler = urg_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
if (bind(acpt_sock, (struct sockaddr *)&recv_adr, sizeof(recv_adr)) == -1)
error_handling("bind() error");
listen(acpt_sock, 5);
serv_adr_sz = sizeof(serv_adr);
recv_sock = accept(acpt_sock, (struct sockaddr *)&serv_adr, &serv_adr_sz);
fcntl(recv_sock, F_SETOWN, getpid()); // 将文件描述符 recv_sock 指向的套接字拥有者(F_SETOWN)改为把getpid函数返回值用做id的进程
state = sigaction(SIGURG, &act, 0); // SIGURG 是一个信号,当接收到MSG_OOB 紧急消息时,系统产生SIGURG信号
while ((str_len = recv(recv_sock, buf, sizeof(buf), 0)) != 0) { // 正常接收
if (str_len == -1)
continue;
buf[str_len] = 0;
puts(buf);
}
close(recv_sock);
close(acpt_sock);
}
void urg_handler(int signo) {
int str_len;
char buf[BUF_SIZE];
str_len = recv(recv_sock, buf, sizeof(buf) - 1, MSG_OOB); // 接受紧急信号
buf[str_len] = 0;
printf("Urgent message: %s \n", buf);
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
上述代码中有一个fcntl
函数,我们先展示结果,后面再进行解释。
编译运行:
gcc oob_send.c -o send
gcc oob_recv.c -o recv
通过 MSG_OOB 可选项传递数据时只返回 1 个字节,而且也不快
的确,通过 MSG_OOB 并不会加快传输速度,而通过信号处理函数 urg_handler
也只能读取一个字节。剩余数据只能通过未设置 MSG_OOB 可选项的普通输入函数读取。因为 TCP 不存在真正意义上的「带外数据」。实际上,MSG_OOB 中的 OOB 指的是 Out-of-band ,而「带外数据」的含义是:
通过完全不同的通信路径传输的数据
即真正意义上的 Out-of-band 需要通过单独的通信路径高速传输数据,但是 TCP 不另外提供,只利用TCP 的紧急模式(Urgent mode)进行传输。 (需要使用单独的模式传输真正意义上的带外数据)
代码中关于:
fcntl(recv_sock, F_SETOWN, getpid());
fcntl
函数用于控制文件描述符,其含义是【这里之后再看吧,看不懂。】
先给出结论,再补充说明紧急模式。MSG_OOB可选项可以带来如下效果:
“嗨!这里有数据需要紧急处理,别磨蹭啦!”
MSG_OOB
的真正的意义在于督促数据接收对象尽快处理数据。这是紧急模式的全部内容,而且TCP “保持传输顺序” 的传输特性依然成立。那么这样的话,这根本不能称之为紧急消息。
TCP的紧急消息无法保证及时入院,但是可以要求急救。下面给出设置MSG_OOB
可选状态下的数据传输过程,如图所示:
上面是:
send(sock, "890", strlen("890"), MSG_OOB);
图上是调用这个函数的缓冲状态。如果缓冲最左端的位置视作偏移量 0 。字符 0 保存于偏移量 2 的位置。另外,字符 0 右侧偏移量为 3 的位置存有紧急指针(Urgent Pointer)。紧急指针指向紧急消息的下一个位置(偏移量加1),同时向对方主机传递一下信息:
紧急指针指向的偏移量为 3 之前的部分就是紧急消息。
也就是说,实际上只用了一个字节表示紧急消息。这一点可以通过图中用于传输数据的 TCP 数据包(段)的结构看得更清楚,如图:
TCP 数据包实际包含更多信息。对于我们了解上述的知识,只需要知道TCP 头部包含如下两种信息:
指定 MSG_OOB
选项的数据包本身就是紧急数据包,并通过紧急指针表示紧急消息所在的位置。紧急消息的意义在于督促消息处理,而非紧急传输形式受限的信息。
同时设置MSG_PEEK
选项和MSG_DONTWAIT
选项,以验证输人缓冲中是否存在接收的数据。设置MSG_PEEK
选项并调用recv
函数时,即使读取了输入缓冲的数据也不会删除 (如果不使用MSG_PEEK的话读取了输入缓冲的数据就会删除
)。因此,该选项通常与MSG_DONTWAIT
合作,用于调用以非阻塞方式验证待读数据存在与否的函数。下面通过示例了解二者含义。
// peek_send.c
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
int main(int argc, char* argv[]) {
int sock;
struct sockaddr_in send_adr;
char message[BUF_SIZE];
if (argc != 3) {
printf ("Usage %s \n" , argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1) error_handling("sock() error");
memset(&send_adr, 0, sizeof(send_adr));
send_adr.sin_family = AF_INET;
send_adr.sin_addr.s_addr = inet_addr(argv[1]);
send_adr.sin_port = htons(atoi(argv[2]));
if (connect(sock, (struct sockaddr*)&send_adr, sizeof(send_adr)) == -1)
error_handling("connect() error");
write(sock, "123", strlen("123"));
close(sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
// peek_recv.c
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(char* message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
char buf[BUF_SIZE];
int str_len, clnt_adr_sz;
if (argc != 2) {
printf("Usage : %s \n" , argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) error_handling("sock() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
listen(serv_sock, 5);
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
while (1) {
str_len = recv(clnt_sock, buf, sizeof(buf) - 1, MSG_PEEK|MSG_DONTWAIT);
if (str_len > 0) break;
}
buf[str_len] = 0;
printf("Buffering %d bytes: %s \n", str_len, buf);
str_len = recv(clnt_sock, buf, sizeof(buf) - 1, 0);
buf[str_len] = 0;
printf("Read again: %s \n", buf);
close(serv_sock);
close(clnt_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
gcc peek_recv.c -o recv
gcc peek_send.c -o send
./recv 9190
./send 127.0.0.1 9190
结果:
通过运行结果可以验证,仅发送1次的数据被读取了2次 (第一次不会清除输入缓冲),因为第一次调用recv函数时设置了MSG_PEEK
可选项。以上就是MSG_PEEK
可选项的功能。
readv & writev 函数的功能可概括如下:
对数据进行整合传输及发送的函数
也就是说,通过 writev 函数可以将分散保存在多个缓冲中的数据一并发送,通过 readv 函数可以由多个缓冲分别接收。因此,适用这 2 个函数可以减少 I/O 函数的调用次数。下面先介绍 writev 函数。
#include
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
/*
成功时返回发送的字节数,失败时返回 -1
filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符.
iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息
iovcnt: 向第二个参数传递数组长度
*/
上述第二个参数中出现的数组 iovec 结构体的声明如下:
struct iovec {
void * iov_base; // 缓冲地址
size_t iov_len; // 缓冲大小
}
在给出示例之前我们首先对writev
函数的使用方法,如下图所示:
上图中writev
的第一个参数1是文件描述符,因此向控制台输出数据,ptr
是存有待发送数据信息的iovec
数组指针。第三个参数为2,因此,从ptr指向的地址开始,共浏览2个iovec结构体变量,发送这些指针指向的缓冲数据。接下来仔细观察图中的iovec结构体数组。ptr[0] 的iov_base指向以A开头的字符串,同时iov_len为3,故发送ABC。而ptr[1] 的iov_base指向数字1,同时iov_len为4,故发送1234。下面给出示例:
// writev.c
#include
#include
int main(int argc, char* argv[]) {
struct iovec vec[2];
char buf1[] = "ABCDEFG";
char buf2[] = "1234567";
int str_len;
vec[0].iov_base = buf1;
vec[0].iov_len = 3;
vec[1].iov_base = buf2;
vec[1].iov_len = 4;
str_len = writev(1, vec, 2);
puts("");
printf("Write bytes: %d \n", str_len);
return 0;
}
这个代码就是根据上述例子编写的,buf1传递前3个,buf2传递前4个,所以最终传的是7个,然后向控制台输出。
编译运行:
gcc writev.c -o writev
./writevi
#include
ssize_t readv(int filedes, const struct iovec* iov, int iovcnt);
/*
成功时返回接收的字节数,失败时返回 -1
filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read一样向向其传递文件或标准输出描述符.
iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息
iovcnt: 向第二个参数传递数组长度
*/
#include
#include
#define BUF_SIZE 100
int main(int argc, char* argv[]) {
struct iovec vec[2];
int str_len;
char buf1[BUF_SIZE] = {0, };
char buf2[BUF_SIZE] = {0, };
vec[0].iov_base = buf1;
vec[0].iov_len = 5;
vec[1].iov_base = buf2;
vec[1].iov_len = BUF_SIZE;
str_len = readv(0, vec, 2);
printf("Read bytes: %d \n", str_len);
printf("First message: %s \n", buf1);
printf("Second message: %s \n", buf2);
return 0;
}
下面是一些说明:设置第一个数据的保存位置和大小。接收数据的大小已指定为5,因此,无论buf1的大小是多少,最多仅能保存5个字节。vec[0]
中注册的缓冲中保存5个字节,剩余数据将保存到vec[1]
中注册的缓冲 (vec[0]
装不下的就装到vec[1]
)。结构体iovec的成员iov_len中应写入接收的最大字节数。readv
函数的第一个参数为0,因此从标准输入接收数据。
编译运行:
gcc readv.c -o rv
./rv
结果:
从图上可以看出,首先截取了长度为 5 的数据输出,然后再输出剩下的。其余的存在了vec[1]中。
实际上,能使用该函数的所有情况都适用。例如,需要传输的数据分别位于不同缓冲(数组)时,需要多次调用 write 函数。此时可通过 1 次 writev 函数调用替代操作,当然会提高效率。同样,需要将输入缓冲中的数据读入不同位置时,可以不必多次调用 read 函数,而是利用 1 次 readv 函数就能大大提高效率。
其意义在于减少数据包个数。假设为了提高效率在服务器端明确禁用了 Nagle 算法。其实 writev 函数在不采用 Nagle 算法时更有价值,如图:
上述示例中待发送的数据分别存在3个不同的地方,此时如果使用write函数则需要3次函数调用。但若为提高速度而关闭了Nagle算法,则极有可能通过3个数据包传递数据。反之,若使用writev函数将所有数据一次性写入输出缓冲,则很有可能仅通过1个数据包传输数据。所以writev函数和readv函数非常有用。总之就是能使用writev和readv的情况下,尽量使用。
暂略。