《TCP IP网络编程》阅读笔记及部分《图解 TCPIP》《图解 HTTP》补充笔记

第一章:理解网络编程和套接字

接受连接请求的套接字(接电话)

服务器端创建的套接字又称为服务器端套接字或监听套接字。

socket

套接字编程。

为了与远程计算机进行数据传输,需要连接到因特网,而编程中的"套接字"就是用来连接该网络的工具。它本身就带有"连接"的含义,如果将其引申,则还可以表示两台计算机之间的网络连接。

#include <sys/socket.h>
int socket(int domain, int type, int protocol)
​
// 成功时返回文件描述符,失败时返回 -1。

调用 socket 函数(安装电话机)时进行的对话

问∶"接电话需要准备什么?"

答∶"当然是电话机!"

bind

给创建好的套接字分配地址信息(IP 地址和端口号)。

#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);
​
// 成功时返回 0,失败时返回 -1。

调用 bind 函数(分配电话号码)时进行的对话

问∶”请问您的电话号码是多少?“

答∶“我的电话号码是123-1234。"

listen

把套接字转化成可接收连接的状态。

#include <sys/socket.h>
int listen(int sockfd, int backlog);
​
// 成功时返回 0,失败时返回-1。

调用 listen 函数(连接电话线)时进行的对话

问∶"已架设完电话机后是否只需连接电话线?"

答∶"对,只需连接就能接听电话。"

accept

有人为了完成数据传输而请求连接,就需要调用以下函数进行受理。

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
​
// 成功时返回文件描述符,失败时返回 -1。

调用 accept 函数(拿起话筒)时进行的对话

问:"电话铃响了,我该怎么办?"

答∶"难道您真不知道?接听啊!"

总结

网络编程中接受连接请求的套接字创建过程可整理如下:

第一步:调用 socket 函数创建套接字。

第二步:调用 bind 函数分配 IP 地址和端口号。

第三步:调用 listen 函数转为可接收请求状态。监听套接字。

第四步:调用 accept 函数受理连接请求。通信套接字。

一个服务器端简单代码

该服务器端收到连接请求后向请求者返回"Hello world!"答复。

// hello_server.c
​
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
​
void error_handling(char *message);
​
/*
*argc 是参数个数,定义为 int。
*argv 是字符串数组,存的是参数,定义为 char** 或者 char* argv[]。
*比如编译好的程序为 my.exe。
*在命令行执行 my.exe123。
*那 argc 就是 4,argv[0] 是"my.exe",argv[1] 是"1",argv[2] 是"2",argv[3] 是"3"。  
*/
int main(int argc, char *argv[])
{
    int serv_sock;
    int clnt_sock;
​
    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;
​
    char message[]="Hello World!";
    
    if(argc!=2){     // 参数数量不对,argv[0] 为 文件名。
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
    
    serv_sock=socket(PF_INET, SOCK_STREAM, 0);  // 1.调用socket函数创建套接字。
    if(serv_sock == -1)
        error_handling("socket() error");
    
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);  // 默认。
    serv_addr.sin_port=htons(atoi(argv[1]));
    
    if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1 ) // 2.调用bind函数分配IP地址和端口号。
        error_handling("bind() error"); 
    
    if(listen(serv_sock, 5)==-1)      // 3.调用listen函数将套接字转为可接收连接状态。
        error_handling("listen() error");
    
    clnt_addr_size=sizeof(clnt_addr);  
    
    // 4.调用accept函数受理连接请求。如果在没有连接请求的情况下调用该函数,则不会返回,直到有连接请求为止。
    // 注意accept是创建一个新的套接字用于和listen创建的服务器端的套接字连接。
    clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
    if(clnt_sock==-1)
        error_handling("accept() error");  
    
    write(clnt_sock, message, sizeof(message));  // 代码执行到本行,则说明已经有了连接请求。
    close(clnt_sock);   // 两个都要 close。
    close(serv_sock);
    return 0;
}
​
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

发送连接请求的套接字(打电话)

connect

客户端程序只有“调用 socket 函数创建套接字"和"调用 connect 函数向服务器端发送连接请求"这两个步骤。

#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen)
​
// 成功时返回 0,失败时返回-1。

一个客户端简单代码

// hello_client.c
​
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
​
void error_handling(char *message);
​
int main(int argc, char* argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len;
    
    if(argc!=3){  // argv[0]是文件名,argv[1]是IP地址,argv[2]是端口号。
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    
    // 创建套接字,但此时套接字并不马上分为服务器端和客户端。如果紧接着调用 bind、listen 函数,将成为服务器端套接字;如果调用 connect 函数,将成为客户端套接字。
    sock=socket(PF_INET, SOCK_STREAM, 0);  // 这行和服务器端一样。
    if(sock == -1)
        error_handling("socket() error");
    
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));
        
    // 调用 connect 函数向服务器端发送连接请求。
    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
        error_handling("connect() error!");
    
    str_len=read(sock, message, sizeof(message)-1);
    if(str_len==-1)
        error_handling("read() error!");
    
    printf("Message from server: %s \n", message);  
    close(sock);
    return 0;
}
​
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

运行上面的服务器和客户端程序

上面的服务器端无法立即重新运行。如果想再次运行,则需要更改之前输入的端口号。

基于 Linux 的文件操作

在 Linux 世界里,socket 也被认为是文件的一种,因此在网络数据传输过程中自然可以使用文件 I/O 的相关函数。

文件描述符

文件描述符对象
0标准输入∶ Standard Input
1标准输出∶ Standard Output
2标准错误∶ Standard Error

文件和套接字一般经过创建过程才会被分配文件描述符。而上面的 3 种输入输出对象即使未经过特殊的创建过程,程序开始运行后也会被自动分配文件描述符。

所以自己创建的文件描述符从 3 开始。

打开文件(open)

#include <sys/types.h>#include <sys/stat.h>#include <fcntl.h>
int open(const char *path, int flag);
​
// 成功时返回文件描述符,失败时返回 -1。
​
// path——文件名的字符串地址,包括路径和名字。
// flag——文件打开模式信息。

下面是此函数第二个参数 flag 可能的常量值及含义。如需传递多个参数,则应通过位或运算(|)符组合并传递。

打开模式含义
O_CREAT必要时创建文件
O_TRUNC删除全部现有数据
O_APPEND维持现有数据,保存到其后面
O_RDONLY只读打开
O_WRONLY只写打开
O_RDWR读写打开

open 和 fopen

open 是 Linux 下的底层系统调用函数,fopen 与 freopen c/c++ 下的标准 I/O 库函数,带输入/输出缓冲。 linxu 下的 fopen 是 open 的封装函数,fopen 最终还是要调用底层的系统调用 open。 所以在 Linux 下如果需要对设备进行明确的控制,那最好使用底层系统调用(open)

关闭文件(close)

#include <unistd.h>
int close(int fd);
​
// 成功时返回 θ,失败时返回 -1。
​
// fd——需要关闭的文件或套接字的文件描述符。

此函数不仅可以关闭文件,还可以关闭套接字。因为在 Linux 世界里,socket 也被认为是文件的一种。

将数据写入文件(write)

#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t nbytes);
​
// 成功时返回写入的字节数,失败时返回 -1。
​
// fd——显示数据传输对象的文件描述符。
// buf——保存要传输数据的缓冲地址值。
// nbytes——要传输数据的字节数。
  • size_t 是通过 typedef 声明的 unsigned int 类型。

  • ssize_t 是通过 typedef 声明的 signed int 类型。

gets、fgets、puts、fputs

gets 函数很危险,gets 没有指定输入字符的大小,限制输入缓冲区的大小,如果输入的字符大于定义的数组长度,会发生内存越界。换行符不作为读取串的内容,读取的换行符被转换为 null(’\0’) 值,并由此来结束字符串。

fgets 函数能够读取指定大小的数据。如果 fgets 函数读到换行符,就会把它存储到字符串中,而不是像 gets 函数那样丢弃它。给定参数 n,fgets 函数只能读取 n-1 个字符(包括换行符)。如果有一行超过 n-1 个字符,那么 fgets 函数将返回一个不完整的行(只读取该行的前 n-1 个字符)。但是,缓冲区总是以 null(’\0’) 字符结尾,对 fgets 函数的下一次调用会继续读取该行。

也就是说,每次调用时,fgets 函数都会把缓冲区的最后一个字符设为 null(’\0’),这意味着最后一个字符不能用来存放需要的数据。所以如果某一行含有 size 个字符(包括换行符),要想把这行读入缓冲区,要把参数 n 设为 size+1,即多留一个位置存储 null(’\0’)。

总结:用 fgets 和 fputs 代替 gets 和 puts

读取文件中的数据(read)

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
​
// 成功时返回接收的字节数(但遇到文件结尾则返回 0,即读完),失败时返回 -1。
​
// fd——显示数据接收对象的文件描述符。
// *buf——要保存接收数据的缓冲地址值。
// nbytes——要接收数据的最大字节数。

文件描述符与套接字的区别

fd1 = socket(PF_INET, SOCK_STREAM, 0);  // 创建套接字
fd2 = open("test.dat", O_CREAT|O_WRONLY|O_TRUNC); // 创建文件描述符

第二章:套接字类型与协议设置

创建套接字

socket再议

#include <sys/socket.h>
int socket(int domain, int type, int protocol)
​
// 成功时返回文件描述符,失败时返回 -1。
    
// domain——套接字中使用的协议族信息。 
// type——套接字数据传输类型信息。
// protocol——计算机间通信中使用的协议信息。

第一个参数:协议族(Protocol Family)

名称协议族
PF_INET(主要用这个)IPv4 互联网协议族
PF_INET6IPv6 互联网协议族
PF_LOCAL本地通信的 UNIX 协议族
PF_PACKET底层套接字的协议族
PF_IPXIPX Novell 协议族

套接字中实际采用的最终协议信息是通过 socket 函数的第三个参数传递的。在指定的协议族范围内通过第一个参数决定第三个参数。

第二个参数:套接字类型(Type)

socket 的第一个参数的协议族有不同的数据传输方式。

两种套接字类型:

  • 套接字类型 1∶面向连接的套接字(SOCK_STREAM)

    特征:

    1. 传输过程中数据不会消失。

    2. 按序传输数据。

    3. 传输的数据不存在数据边界。

    收发数据的套接字内部有缓冲。通过套接字传输的数据将保存到该数组。因此,收到数据并不意味着马上调用 read 函数。只要不超过数组容量,则有可能在数据填充满缓冲后通过 1 次 read 函数调用读取全部,也有可能分成多次 read 函数调用进行读取。也就是说,在面向连接的套接字中,read 函数和 write 函数的调用次数并无太大意义。所以说面向连接的套接字不存在数据边界。

    可靠的、按序传递的、基于字节的面向连接的数据传输方式的套接字。

  • 套接字类型 2∶面向消息的套接字(SOCK_DGRAM)

    特征:

    1. 强调快速传输而非传输顺序。

    2. 传输的数据可能丢失也可能损毁。

    3. 传输的数据有数据边界。

    4. 限制每次传输的数据大小。

    不可靠的、不按序传递的、以数据的高速传输为目的的套接字。

第三个参数:协议的最终选择

传递前两个参数即可创建所需套接字。所以大部分情况下可以向第三个参数传递 0。除非遇到以下这种情况:"同一协议族中存在多个数据传输方式相同的协议"。

数据传输方式相同,但协议不同,此时需要通过第三个参数具体指定协议信息。

示例1:面向连接的套接字,TCP套接字

参数 PF_INET 指 IPv4 网络协议族,SOCK_STREAM 是面向连接的数据传输。满足这 2 个条件的协议只有 IPPROTO_TCP,因此可以如下调用 socket 函数创建套接字,这种套接字称为 TCP 套接字。

int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP) // IPPROTO_TCP这个参数也可以换成0.

// IPv4协议族中面向消息的套接字

TCP套接字示例:验证传输的数据不存在数据边界

为验证这一点,需要让 write 函数的调用次数不同于 read 函数的调用次数。因此,在客户端中分多次调用 read 函数以接收服务器端发送的全部数据。

// tcp_client.c 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char* argv[])
{
	int sock;
	struct sockaddr_in serv_addr;
	char message[30];
	int str_len=0;
	int idx=0, read_len=0;
	
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
    // 创建 TCP 套接字。若前两个参数传递 PF_INET、SOCK_TREAM,则可以省略第三个参数 IPPROTO_TCP。
	sock=socket(PF_INET, SOCK_STREAM, 0); 

	if(sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_addr.sin_port=htons(atoi(argv[2]));
		
	if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
		error_handling("connect() error!");

	while(read_len=read(sock, &message[idx++], 1))
	{
		if(read_len==-1)
			error_handling("read() error!");
		
		str_len+=read_len;
	}

	printf("Message from server: %s \n", message);
	printf("Function read call count: %d \n", str_len);
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

示例2:面向消息的套接字,UDP套接字

SOCK_DGRAM 指的是面向消息的数据传输方式,满足上述条件的协议只有 IPPROTO_UDP。因此,可以如下调用 socket 函数创建套接字,这种套接字称为 UDP 套接字。

int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);

// IPv4协议族中面向消息的套接字

第三章:地址族与数据序列

分配给套接字的 IP 地址与端口号

  • IP 是 Internet Protocol(网络协议)的简写,是为收发网络数据而分配给计算机的值。

    IPv4(Internet Protocol version 4)——4 字节地址族 IPv6(Internet Protocol version 6)——16 字节地址族

    分为网络 ID 和主机 ID。

  • 端口号并非赋予计算机的值,而是为区分程序中创建的套接字而分配给套接字的序号。

    IP 用于区分计算机,只要有 IP 地址就能向目标主机传输数据,但仅凭这些无法传输给最终的应用程序。IP 用于从外面接收数据(这些数据含有端口号),端口号将这些数据具体分配给哪个应用。(IP 主外,端口号主内)

    端口号就是在同一操作系统内为区分不同套接字而设置的,因此无法将 1 个端口号分配给不同套接字。端口号由 16 位构成,可分配的端口号范围是 0-65535。但 0-1023 是知名端口,一般分配给特定应用程序,所以应当分配此范围之外的值。另外,虽然端口号不能重复,但 TCP 套接字和 UDP 套接字不会共用端口号,所以允许重复。例如,如果某 TCP 套接字使用 9190 号端口,则其他 TCP 套接字就无法使用该端口号,但 UDP 套接字可以使用。

  • 总之,数据传输目标地址同时包含 IP 地址和端口号,只有这样,数据才会被传输到最终的目的应用程序。

地址信息的表示

表示 IPv4 地址的结构体(sockaddr_in 和 sockaddr )

需要地址族、IP 地址、端口号。

struct sockaddr_in{
    sa_family_t       sin_family;    // 地址族                     2 字节
    uint16_t          sin_port;      // 16 位 TCP/UDP 端口号       2 字节
    struct in_addr    sin_addr;      // 32 位 IP 地址              4 字节
    char              sin_zero[8];   // 不使用                     8 字节   一共 16 字节
};

其中,struct in_addr 如下:

struct in_addr{
    In_addr_t s_addr; // 32 位 IPv4 地址
};

使用 struct sockaddr_in 的理由

像 bind 函数是这样的:

bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))

用到 struct sockaddr 结构体,有一个强制类型转换。但是 sockaddr 用起来比较麻烦,因为 struct sockaddr 结构体长这样:

struct sockaddr{
 sa_family_t   sin_family;    // 地址族           2 字节
 char          sa_data[14];   // 地址信息         14 字节    一共 16 字节
};

和 sockaddr_in 相比,sockaddr 把 IP 地址和端口号合起来了

由于 sockaddr_in 弄了一个 sin_zero,因此二者长度一样,都是 16 个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向 sockaddr_in 结构的指针也可以指向 sockaddr。

一般先把 sockaddr_in 变量赋值后,强制类型转换后传入用 sockaddr 做参数的函数。即,sockaddr_in 用于 socket 定义和赋值,sockaddr 用于函数参数

网络字节序与地址变换

大小端

  • 大端序: 高位字节存放到低位地址。先保存最高位字节。网络字节序。

  • 小端序: 高位字节存放到高位地址。主机字节序。

通过网络传输数据时约定为大端序

字节序转换(htons、ntohs、htonl、ntohl)

  1. unsigned short htons(unsigned short)——把 short 型数据从主机字节序转化为网络字节序,转换后高字节内存放低字节地址。

  2. unsigned short ntohs(unsigned short)

  3. unsigned long htonl(unsigned long)

  4. unsined long ntohl(unsigned long)

h - host 主机,主机字节序
to - 转换成什么
n - network  网络字节序
s - short  unsigned short
l  - long  unsigned int
#include <arpa/inet.h>
// 转换端口
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序
// 转IP
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序

转换示例:

#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, char *argv[])
{
    unsigned short host_port = 0x1234;
    unsigned short net_port;
    unsigned long host_addr = 0x12345678;
    unsigned long net_addr;

    net_port = htons(host_port);
    net_addr = htonl(host_addr);

    printf("Host ordered port: %#x \n", host_port);        // Host ordered port: 0x1234 
    printf("Network ordered port: %#x \n", net_port);      // Network ordered port: 0x3412 
    printf("Host ordered address: %#lx \n", host_addr);    // Host ordered address: 0x12345678 
    printf("Network ordered address: %#lx \n", net_addr);  // Network ordered address: 0x78563412 
    return 0;
}

网络地址的初始化与分配

将字符串信息转换为网络字节序的整数型(inet_addr、inet_aton、inet_pton )

inet_addr

#include <arpa/inet.h>
in_addr_t inet_addr(const char * string);

// 成功时返回 32 位大端序整数型值,失败时返回 INADDR_NONE。

inet_addr 函数会将字符串形式的 IP 地址转换成 32 位整数型数据。此函数在转换类型的同时进行网络字节序转换

示例:

// inet_addr.c
#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, char *argv[])
{
	char *addr1="127.212.124.78";
	char *addr2="127.212.124.256";  // 256 > 255,将返回 INADDR_NONE。

	unsigned long conv_addr=inet_addr(addr1);
	if(conv_addr==INADDR_NONE)
		printf("Error occured! \n");
	else
		printf("Network ordered integer addr: %#lx \n", conv_addr);
	
	conv_addr=inet_addr(addr2);
	if(conv_addr==INADDR_NONE)
		printf("Error occureded \n");
	else
		printf("Network ordered integer addr: %#lx \n\n", conv_addr);
	return 0;
}

/*
Network ordered integer addr: 0x4e7cd47f   // 先保存高内存,是大端序。
Error occureded 
*/

inet_aton

inet_aton 函数与 inet_addr 函数在功能上完全相同,也将字符串形式 IP 地址转换为 32 位网络字节序整数并返回。只不过该函数利用了 in_addr 结构体,且其使用频率更高。返回值也不同。

实际编程中若要调用 inet_addr 函数,需将转换后的 IP 地址信息代入 sockaddr_in 结构体中声明的 in_addr 结构体变量。而 inet_aton 函数则不需此过程。原因在于,若传递 in_addr 结构体变量地址值,函数会自动把结果填入该结构体变量。

inet_pton

一个支持 IPv6 的新函数。

#include <arpa/inet.h>
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
  af:地址族: AF_INET  AF_INET6
  src:需要转换的点分十进制的IP字符串
  dst:转换后的结果保存在这个里面

将网络字节序的整数型转化为字符串(inet_ntoa)

inet_ntoa

#include <arpa/inet.h>
char * inet_ntoa(struct in_addr adr);

// 成功时返回转换的字符串地址值,失败时返回 -1。

该函数将通过参数传入的整数型 IP 地址转换为字符串格式并返回。但调用时需小心,返回值类型为 char 指针。返回字符串地址意味着字符串已保存到内存空间,但该函数未向程序员要求分配内存,而是在内部申请了内存并保存了字符串。也就是说,调用完该函数后,应立即将字符串信息复制到其他内存空间。因为,若再次调用 inet_ntoa 函数,则有可能覆盖之前保存的字符串信息。总之。再次调用 inet_ntoa 函数前返回的字符串地址值是有效的。若需要长期保存,则应将字符串复制到其他内存空间。

总结:具有不可重入性。

示例:

char* szValuel = inet ntoa("1.2.3.4");
char* szValue2 = inet ntoa("10.194.71.60");
printf("address 1∶号s\n",szValuel);
printf("address 2:8s\n",szValue2 );

/*
address1;10.194.71.60 
address2:10.194.71.60
*/

inet_ntop

// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
  af: 地址族: AF_INET  AF_INET6
  src: 要转换的ip的整数的地址
  dst: 转换成IP地址字符串保存的地方
  size:第三个参数的大小(数组的大小)
返回值:返回转换后的数据的地址(字符串),和 dst 是一样的

服务器端网络地址初始化(INADDR_ANY)

struct sockaddr_in addr; 
char * serv_ip = "211.217.168.13";          // 声明 IP 地址字符串
char * serv_port = "9190";                  // 声明端口号字符串
memset(&addr, 0, sizeof(addr));             // 结构体变量 addr 的所有成员初始化为 0。这么做是为了将 sockaddr_in 结构体的成员 sin_zero 初始化为 0。
addr.sin_family = AF_INET;                  // 指定地址族
addr.sin_addr.s_addr = inet_addr(serv_ip);  // 基于字符串的 IP 地址初始化
addr.sin_port = htons(atoi(serv_port));     // 基于字符串的端口号初始化

每次创建服务器端套接字都要输入 IP 地址会有些繁琐,此时可如下初始化地址信息:

struct sockaddr_in addr; 
// char * serv_ip = "211.217.168.13";          // 删除这行
char * serv_port = "9190";                  // 声明端口号字符串
memset(&addr, 0, sizeof(addr));             // 结构体变量 addr 的所有成员初始化为 0。
addr.sin_family = AF_INET;                  // 指定地址族
addr.sin_addr.s_addr = htonl(INADDR_ANY);   // 这里改变
addr.sin_port = htons(atoi(serv_port));     // 基于字符串的端口号初始化

与之前方式最大的区别在于,利用常数 INADDR_ANY 分配服务器端的 IP 地址。若采用这种方式,则可自动获取运行服务器端的计算机 IP 地址,不必亲自输入。服务器端中优先考虑这种方式。而客户端中除非带有一部分服务器端功能,否则不会采用。

客户端地址信息初始化

服务器端的准备工作通过 bind 函数完成。而客户端则通过 connect 函数完成。因此,函数调用前需准备的地址值类型也不同。服务器端声明 sockaddr_in 结构体变量,将其初始化为赋予服务器端 IP 和套接字的端口号,然后调用 bind 函数;而客户端则声明 sockaddr_in 结构体,并初始化为要与之连接的服务器端套接字的 IP 和端口号,然后调用 connect 函数。

向套接字分配网络地址

bind再议

#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);

// 成功时返回 0,失败时返回 -1。

// sockfd——要分配地址信息(IP 地址和端口号)的套接字文件描述符。
// myaddr——存有地址信息的结构体变量地址值。
// addrlen——第二个结构体变量的长度。一般写成 sizeof(serv_addr)。

套接字初始化:

int serv_sock;
struct sockaddr_in serv_addr;
char * serv_port ="9190";

/* 创建服务器端套接字(监听套接字) */
serv_sock = socket(PF_INET, SOCK_STREAM, O);

/*地址信息初始化 */
memset(&serv_addr,0,sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(serv_port));

/*分配地址信息 */
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

第四章:基于 TCP 的服务器端/客户端(1)(电话)

TCP 充分地实现了数据传输时各种控制功能,可以进行丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。此外,TCP 作为一种面向有连接的协议,只有在确认通信对端存在时才会发送数据,从而可以控制通信流量的浪费。

协议栈

TCP/IP 协议栈

UDP 协议栈

链路层

链路层是物理链接领域标准化的结果,也是最基本的领域,专门定义 LAN、WAN、MAN 等网络标准。若两台主机通过网络进行数据交换,则需要下图所示的物理连接,链路层就负责这些标准。

IP 层

准备好物理连接后就要传输数据。为了在复杂的网络中传输数据,首先需要考虑路径的选择。向目标传输数据需要经过哪条路径?解决此问题就是 IP 层。 IP 本身是面向消息的、不可靠的协议。每次传输数据时会帮我们选择路径,但并不一致。如果传输中发生路径错误,则选择其他路径;但如果发生数据丢失或错误,则无法解决。换言之,IP 协议无法应对数据错误。

传输层(TCP/UDP层)

IP 层解决数据传输中的路径选择问题,只需照此路径传输数据即可。TCP 和 UDP 层以 IP 层提供的路径信息为基础完成实际的数据传输,故该层又称传输层。

IP 层只关注 1 个数据包(数据传输的基本单位)的传输过程。因此,即使传输多个数据包,每个数据包也是由 IP 层实际传输的,也就是说传输顺序及传输本身是不可靠的。若只利用 IP 层传输数据,则有可能导致后传输的数据包 B 比先传输的数据包 A 提早到达。另外,传输的数据包 A、B、C 中有可能只收到 A 和 C,甚至收到的 C 可能已损毁。反之,若添加TCP协议可以解决这些问题。

应用层

上述内容是套接字通信过程中自动处理的。选择数据传输路径、数据确认过程都被隐藏到套接字内部。而与其说是"隐藏",倒不如"使程序员从这些细节中解放出来"的表达更为准确。程序员编程时无需考虑这些过程,但这并不意味着不用掌握这些知识。只有掌握了这些理论,才能编写出符合需求的网络程序。 总之,向各位提供的工具就是套接字,大家只需利用套接字编出程序即可。编写软件的过程中,需要根据程序特点决定服务器端和客户端之间的数据传输规则(规定),这便是应用层协议。网络编程的大部分内容就是设计并实现应用层协议。

进入等待连接请求状态

listen再议

将服务器端之前创建的套接字进入可接收状态。

只有调用了 listen 函数,客户端才能进入可发出连接请求的状态。这时客户端才能调用 connect 函数(若提前调用将发生错误)。相当于门卫。

#include <sys/socket.h>
int listen(int sockfd, int backlog);

// 成功时返回 0,失败时返回 -1。

// sock——希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数成为服务器端套接字(监听套接字)。
// backlog——连接请求等待队列(Queue)的长度,若为 5,则队列长度为 5,表示最多使 5 个连接请求进入队列。

sockfd 参数指定被监听的 socket。backlog 参数提示内核监听队列的最大长度。监听队列的长度如果超过 backlog,服务器将不受理新的客户连接,客户端也将收到 ECONNREFUSED 错误信息。

受理客户端连接请求

accept再议

注意:不是使用服务器端的套接字,服务器端的套接字是作为门卫的。accept 函数会自动再创建一个套接字。

创建一个新的套接字来与客户套接字创建连接通道。

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

// 成功时返回文件描述符,失败时返回 -1。

// sockfd——服务器套接字的文件描述符。
// addr——保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量参数填充客户端地址信息。
// addrlen——第二个参数 addr 结构体的长度,但是存有长度的变量地址。函数调用完成后,该变量即被填入客户端地址长度。

TCP 客户端的默认函数调用顺序

connect再议

#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen)

// 成功时返回 0,失败时返回 -1。
    
// sock——客户端套接字文件描述符。
// servaddr——保存目标服务器端地址信息的变量地址值。
// addrlen——以字节为单位传递已传递给第二个结构体参数 servaddr 的地址变量长度。

需要注意,所谓的"接收连接"并不意味着服务器端调用 accept 函数,其实是服务器端把连接请求信息记录到等待队列。因此 connect 函数返回后并不立即进行数据交换。

客户端的 IP 地址和端口在调用 connect 函数时自动分配,无需调用标记的 bind 函数进行分配。

实现回声服务器端/客户端

实现要求:

  1. 服务器端在同一时刻只与一个客户端相连,并提供回声服务。

  2. 服务器端依次向 5 个客户端提供服务并退出。

  3. 客户端接收用户输入的字符串并发送到服务器端。

  4. 服务器端将接收的字符串数据传回客户端,即"回声"。

  5. 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。

// echo_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock, clnt_sock;
	char message[BUF_SIZE];
	int str_len, i;
	
	struct sockaddr_in serv_adr;
	struct sockaddr_in clnt_adr;
	socklen_t clnt_adr_sz;
	
	if(argc!=2) {
		printf("Usage : %s <port>\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");
	
	clnt_adr_sz=sizeof(clnt_adr);
    
    // 为处理 5 个客户端连接而添加的循环语句。共调用 5 次 accept 函数,依次向 5 个客户端提供服务。
    // 因为 accept 返回的是客户端的套接字。
	for(i=0; i<5; i++)
	{
		clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
		if(clnt_sock==-1)
			error_handling("accept() error");
		else
			printf("Connected client %d \n", i+1);
        
	    // 实际完成回声服务的代码,原封不动地传输读取的字符串。
		while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
			write(clnt_sock, 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len;
    struct sockaddr_in serv_adr;

    if(argc!=3) {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock=socket(PF_INET, SOCK_STREAM, 0);   
    if(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=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!");
    else
        puts("Connected...........");

    while(1) 
    {
        fputs("Input message(Q to quit): ", stdout);
        fgets(message, BUF_SIZE, stdin);

        if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
            break;

        write(sock, message, strlen(message));

        // 成功时返回接收的字节数(但遇到文件结尾则返回θ),失败时返回-1。
        // 这里只接收 BUF_SIZE-1 个字节,因为最后要留一个给 \0.
        str_len=read(sock, message, BUF_SIZE-1);   
        message[str_len]=0;   // 字符数组最后一个是 0 表示空。
        printf("Message from server: %s", message);
    }

    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

上面的客户端代码存在的问题:

由于"TCP不存在数据边界" ,上述客户端是基于 TCP 的,因此,多次调用 write 函数传递的字符串有可能一次性传递到服务器端。此时客户端有可能从服务器端收到多个字符串,这不是我们希望看到的结果。还需考虑服务器端的如下情况:

"字符串太长,需要分2个数据包发送!"

服务器端希望通过调用 1 次 write 函数传输数据,但如果数据太大,操作系统就有可能把数据分成多个数据包发送到客户端。另外,在此过程中,客户端有可能在尚未收到全部数据包时就调用 read 函数。

第五章:基于 TCP 的服务器端/客户端(2)

解决上一章的遗留问题

上一章最后的时候指明客户端存在问题,即有可能通过 write 函数一次传送数据,但是通过 read 函数多次接收。

"既然回声客户端会收到所有字符串数据,是否只需多等一会儿?过一段时间后再调用 read 函数是否可以一次性读取所有字符串数据?"

解决办法:因为可以提前确定接收数据的大小。若之前传输了 20 字节长的字符串,则在接收时循环调用 read 函数读取 20 个字节即可。

修改客户端的 while 循环:

while(1) 
{
    fputs("Input message(Q to quit): ", stdout);
    fgets(message, BUF_SIZE, stdin);

    if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
        break;

    str_len=write(sock, message, strlen(message));

    // 修改此处-------------------------------------------------
    recv_len=0;
    while(recv_len<str_len)
    {
        recv_cnt=read(sock, &message[recv_len], BUF_SIZE-1);   // 注意这里取 message[recv_len] 的地址
        if(recv_cnt==-1)
            error_handling("read() error!");
        recv_len+=recv_cnt;
    }
    //---------------------------------------------------------
    
    message[recv_len]=0;
    printf("Message from server: %s", message);
}

上面的规则就是应用层协议

TCP 原理

TCP 套接字中的 I/O 缓冲

write 函数调用后并非立即传输数据,read 函数调用后也并非马上接收数据。更准确地说,write 函数调用瞬间,数据将移至输出缓冲;read 函数调用瞬间,从输入缓冲读取数据。

调用 write 函数时,数据将移到输出缓冲,在适当的时候(不管是分别传送还是一次性传送)传向对方的输入缓冲。这时对方将调用 read 函数从输入缓冲读取数据。这些 I/O 缓冲特性可整理如下:

  • I/O 缓冲在每个 TCP 套接字中单独存在。

  • I/O 缓冲在创建套接字时自动生成。

  • 即使关闭套接字也会继续传递输出缓冲中遗留的数据。

  • 关闭套接字将丢失输入缓冲中的数据。

TCP 套接字从创建到消失的3步(三次握手和四次挥手)

  1. 与对方套接字建立连接。

    三次握手

    套接字A∶"你好,套接字B。我这儿有数据要传给你,建立连接吧。“

    套接字B∶"好的,我这边已就绪。"

    套接字A∶"谢谢你受理我的请求。"

    1. [SYN] SEQ:1000,ACK:-——"现传递的数据包序号为 1000,如果接收无误,请通知我向您传递 1001 号数据包。"

    2. [SYN+ACK] SEQ:2000,ACK:1001——"现传递的数据包序号为 2000,如果接收无误,请通知我向您传递 2001 号数据包。"
"刚才传输的 SEQ 为 1000 的数据包接收无误,现在请传递 SEQ 为 1001 的数据包。"

    3. [ACK] SEQ:1001,ACK: 2001——"已正确收到传输的 SEQ 为 2000 的数据包,现在可以传输 SEO 为 2001 的数据包。"

  2. 与对方套接字进行数据交换。

    上图给出了主机 A 分 2 次(分 2 个数据包)向主机 B 传递 200 字节的过程。首先,主机 A 通过 1 个数据包发送 100 个字节的数据,数据包的 SEO 为 1200。主机 B 为了确认这一点,向主机 A 发送 ACK 1301 消息。如果传输错误会怎样?重新传。

  3. 断开与对方套接字的连接。

    TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传输时直接断掉连接会出问题,所以断开连接时需要双方协商。断开连接时双方对话如下:

    四次握手

    套接字A∶"我希望断开连接。"

    套接字B∶"哦,是吗?请稍候。"

    套接字B∶"我也准备就绪,可以断开连接。"

    套接字A∶"好的,谢谢合作。"

FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过程经历 4 个阶段,因此又称四次握手。图中向主机 A 传递了两次 ACK 5001,也许这会让各位感到困惑。其实,第二次 FIN 数据包中的 ACK 5001 只是因为接收 ACK 消息后未接收数据而重传的。

上面的 100 字节数据是一段。

在建立 TCP 连接的同时,也可以确定发送数据包的单位,我们也可以称其为“最大消息长度”(MSS)。

MSS 是在三次握手的时候,在两端主机之间被计算得出。两端的主机在发出建立连接的请求时,会在 TCP 首部中写入 MSS 选项,告诉对方自己的接口能够适应的 MSS 的大小 。然后会在两者之间选择一个较小的值投入使用。

利用窗口控制提高速度

确认应答不再是以每个分段,而是以更大的单位进行确认时,转发时间将会被大幅度的缩短。也就是说,发送端主机,在发送了一个段以后不必要一直等待确认应答,而是继续发送。

上图中窗口大小是 4 个段。

这个机制实现了使用大量的缓冲区(缓冲区在此处表示临时保存收发数据的场所。通常是在计算机内存中开辟的一部分空间。) ,通过对多个段同时进行确认应答的功能。

发送端主机在等到确认应答返回之前,必须在缓冲区中保留 这部分数据。 当数据发出后若如期收到 确认应答就可以不用再进行重发,此时数据就可以从缓存区清除。

窗口控制与重发控制

应答丢失:

在没有使用窗口控制的时候,没有收到确认应答的数据都会被重发。而使用了窗口控制,某些确认应答即便丢失也无需重发。

报文丢失:

当某一报文段丢失后,发送端会一直收到序号为 1001 的确认应答,这个确认应答好像在提醒发送端“我想接收的是从 1001开始的数据”。因此,在窗口比较大,又出现报文段丢失的情况下,同一个序号的确认应答将会被重复不断地返回。而发送端主机如果连续 3 次收到同一个确认应答(之所以连续收到 3 次而不是两次的理由是因为,即使数据段的序号被替换两次也不会触发重发机制。) ,就会将其所对应的数据进行重发。这种机制被称作高速重发控制。

拥塞控制

有了 TCP 的窗口控制,收发主机之间即使不再以一个数据段为单位发送确认应答,也能够连续发送大量数据包。然而,如果在通信刚开始时就发送大量数据,也可能会引发其他问题。

TCP 为了防止该问题的出现,在通信一开始时就会通过一个叫做慢启动的算法得出的数值,对发送数据量进行控制。

第六章:基于 UDP 的服务器端/客户端(信件)

IP 和 UDP 的作用:

 IP 的作用就是让离开主机 B 的 UDP 数据包准确传递到主机 A。但把 UDP 包最终交给主机 A 的某一 UDP 套接字的过程则是由 UDP 完成的。UDP 最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP 套接字。

UDP 服务器端/客户端不像 TCP 那样在连接状态下交换数据,因此与 TCP 不同,无需经过连接过程。也就是说,不必调用 TCP 连接过程中调用的 listen 函数和 accept 函数。UDP 中只有创建套接字的过程和数据交换过程。

UDP 服务器端和客户端均只需 1 个套接字。

TCP 中,套接字之间应该是一对一的关系。若要向 10 个客户端提供服务,则除了守门的服务器套接字外,还需要 10 个服务器端套接字。但在 UDP 中,不管是服务器端还是客户端都只需要 1 个套接字。之前解释 UDP 原理时举了信件的例子,收发信件时使用的邮筒可以比喻为 UDP 套接字。只要附近有 1 个邮筒,就可以通过它向任意地址寄出信件。同样,只需 1 个 UDP 套接字就可以向任意主机传输数据。

基于 UDP 的数据 I/O 函数

sendto

创建好 TCP 套接字后,传输数据时无需再添加地址信息。因为 TCP 套接字将保持与对方套接字的连接。换言之,TCP 套接字知道目标地址信息。但 UDP 套接字不会保持连接状态(UDP 套接字只有简单的邮筒功能),因此每次传输数据都要添加目标地址信息。这相当于寄信前在信件中填写地址。接下来介绍填写地址并传输数据时调用的 UDP 相关函数。

调用 sendto 函数时自动分配 IP 和端口号,因此,UDP 客户端中通常无需额外的地址分配过程。所以之前示例中省略了该过程,这也是普遍的实现方式。

#include <sys/socket.h>
ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen);

// 成功时返回传输的字节数,失败时返回 -1。

// sock——用于传输数据的 UDP 套接字文件描述符。
// buff——保存待传输数据的缓冲地址值。
// nbytes——待传输的数据长度,以字节为单位。
// flags——可选项参数,若没有则传递 0。
// to——存有目标地址信息的 sockaddr 结构体变量的地址值。
// addrlen——传递给参数 to 的地址值结构体变量长度。

recvfrom

UDP 数据的发送端并不固定,因此该函数定义为可接收发送端信息的形式,也就是将同时返回 UDP 数据包中的发送端信息。

#include <sys/socket.h>
ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);

// 成功时返回传输的字节数,失败时返回 -1。

// sock——用于传输数据的 UDP 套接字文件描述符。
// buff——保存待传输数据的缓冲地址值。
// nbytes——待传输的数据长度,以字节为单位。
// flags——可选项参数,若没有则传递 0。
// from——存有发送端地址信息的sockaddr结构体变量的地址值。
// addrlen——保存参数 from 的结构体变量长度的变量地址值。

实现回声服务器端/客户端

UDP 不同于 TCP,不存在请求连接和受理过程,因此在某种意义上无法明确区分服务器端和客户端。只是因其提供服务而称为服务器端。

// uecho_server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t clnt_adr_sz;
	
	struct sockaddr_in serv_adr, clnt_adr;
	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	
	serv_sock=socket(PF_INET, SOCK_DGRAM, 0); // 为了创建 UDP 套接字,向 socket 函数第二个参数传递 SOCK_DGRAM。
	if(serv_sock==-1)
		error_handling("UDP socket creation 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");

	while(1)  // 无限循环。也就是说,close 函数不会执行,没有太大意义。
	{
		clnt_adr_sz=sizeof(clnt_adr);
        // 利用 bind 分配的地址接收数据。不限制数据传输对象。
		str_len=recvfrom(serv_sock, message, BUF_SIZE, 0, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); 
        
        // 通过 recvfrom 函数调用同时获取数据传输端的地址。正是利用该地址将接收的数据逆向重传。
		sendto(serv_sock, message, str_len, 0, (struct sockaddr*)&clnt_adr, clnt_adr_sz);
	}	
	close(serv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}
// uecho_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;
	
	struct sockaddr_in serv_adr, from_adr;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);   // 创建 UDP 套接字。现在只需调用数据收发函数。
	if(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=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		
		sendto(sock, message, strlen(message), 0, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
		adr_sz=sizeof(from_adr);
		str_len=recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr*)&from_adr, &adr_sz);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

存在数据边界的 UDP 套接字

TCP 数据传输中不存在边界,这表示"数据传输过程中调用 I/O 函数的次数不具有任何意义。"

相反,UDP 是具有数据边界的协议,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数应和输出函数的调用次数完全一致,这样才能保证接收全部已发送数据。例如,调用 3 次输出函数发送的数据必须通过调用 3 次输入函数才能接收完。

已连接(connected)UDP套接字与未连接(unconnected)UDP套接字

TCP 套接字中需注册待传输数据的目标 IP 和端口号,而 UDP 中则无需注册。因此,通过 sendto 函数传输数据的过程大致可分为以下 3个阶段:

  1. 第1阶段:向 UDP 套接字注册目标 IP 和端口号。

  2. 第2阶段:传输数据。

  3. 第3阶段:删除 UDP 套接字中注册的目标地址信息

每次调用 sendto 函数时重复上述过程。每次都变更目标地址,因此可以重复利用同一 UDP 套接字向不同目标传输数据。这种未注册目标地址信息的套接字称为未连接套接字,反之,注册了目标地址的套接字称为连接 connected 套接字。UDP 套接字默认属于未连接套接字

要与同一主机进行长时间通信时,将 UDP 套接字变成已连接套接字会提高效率。上述三个阶段中,第一个和第三个阶段占整个通信过程近 1/3 的时间,缩短这部分时间将大大提高整体性能。

创建已连接UDP套接字

创建已连接 UDP 套接字的过程格外简单,只需针对 UDP 套接字调用 connect 函数

sock= socket(PF_INET,SOCK_DGRAM,0);
memset(&adr,0,sizeof(adr));
adr.sin_family = AF_INET;
adr.sin_addr.s_addr = ...
adr,sin_port = ...
connect(sock,(struct sockaddr *)&adr, sizeof(adr));

之后就与 TCP 套接字一样,每次调用 sendto 函数时只需传输数据。因为已经指定了收发对象,所以不仅可以使用 sendto、recvfrom 函数,还可以使用 write、read 函数进行通信。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;         // 多余变量!
	
	struct sockaddr_in serv_adr, from_adr;  // 不再需要from_adr!
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);   
	if(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=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr));

	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		/*
		sendto(sock, message, strlen(message), 0, 
					(struct sockaddr*)&serv_adr, sizeof(serv_adr));
		*/
		write(sock, message, strlen(message));

		/*
		adr_sz=sizeof(from_adr);
		str_len=recvfrom(sock, message, BUF_SIZE, 0, 
					(struct sockaddr*)&from_adr, &adr_sz);
		*/
		str_len=read(sock, message, sizeof(message)-1);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

第七章:优雅地断开套接字连接

之前是调用 close 函数单方面断开连接。

半关闭

完全断开不仅指无法传输数据,而且也不能接收数据。

两台主机通过套接字建立连接后进入可交换数据的状态,又称"流形成的状态"。

一旦两台主机间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。

shutdown

#include <sys/socket.h>
int shutdown(int sock, int howto);

// 成功时返回 0,失败时返回 -1。

// sock——需要断开的套接字文件描述符。
// howto——传递断开方式信息。

调用上述函数时,第二个参数决定断开连接的方式,其可能值如下所示:

  1. SHUT_RD∶断开输入流。

  2. SHUT_WR∶断开输出流。

  3. SHUT_RDWR∶同时断开 I/O 流。

若向 shutdown 的第二个参数传递 SHUT_RD,则断开输入流,套接字无法接收数据。即使输入缓冲收到数据也会抹去,而且无法调用输入相关函数。

如果向 shutdown 函数的第二个参数传递 SHUT_WR,则中断输出流,也就无法传输数据。但如果输出缓冲还留有未传输的数据,则将传递至目标主机

最后,若传入 SHUT_RDWR,则同时中断 I/O 流。这相当于分 2 次调用 shutdown,其中一次以 SHUT_RD 为参数,另一次以 SHUT_WR 为参数。

基于半关闭的文件传输程序

服务器端在把文件数据传输出去后要关闭输出流。

// file_server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#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;
	
	struct sockaddr_in serv_adr, clnt_adr;
	socklen_t clnt_adr_sz;
	
	if(argc!=2) {
		printf("Usage: %s <port>\n", argv[0]);
		exit(1);
	}
	
    // 打开文件以向客户端传输服务器端源文件 file_server.c。就是把这个文件的内容传过去。"rb",只读打开一个二进制文件。
	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)   // 为向客户端传输文件数据而编写的循环语句。此客户端是上面的 accept 函数调用中连接的。
	{
        /*
        size_t fread ( void *buffer, size_t size, size_t count, FILE *stream) ;
        参 数:
        buffer 用于接收数据的内存地址
        size 要读的每个数据项的字节数,单位是字节
        count 要读count个数据项,每个数据项size个字节.
        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);	// 发送文件后针对输出流进行半关闭。这样就向客户端传输了 EOF,而客户端也知道文件传输已完成。
	read(clnt_sd, buf, BUF_SIZE); // 只关闭了输出流,依然可以通过输入流接收数据。
	printf("Message from client: %s \n", buf);
	
	fclose(fp);
	close(clnt_sd); 
    close(serv_sd);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}
// file client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sd;
	FILE *fp;
	
	char buf[BUF_SIZE];
	int read_cnt;
	struct sockaddr_in serv_adr;
	if(argc!=3) {
		printf("Usage: %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	fp=fopen("receive.dat", "wb");      // 创建新文件以保存服务器端传输的文件数据。
	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=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));

	connect(sd, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
	
	while((read_cnt=read(sd, buf, BUF_SIZE ))!=0)  // 接收数据并保存到fopen创建的文件,直到接收EOF为止。
		fwrite((void*)buf, 1, read_cnt, fp);
	
	puts("Received file data");
	write(sd, "Thank you", 10);   // 向服务器端发送表示感谢的消息。若服务器端未关闭输入流,则可接收此消息。
	fclose(fp);
	close(sd);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

第八章:域名及网络地址

DNS(域名系统)是对 IP 地址和域名进行相互转换的系统,其核心是 DNS 服务器。在应用层

在浏览器地址栏中输入 Naver 网站的 IP 地址 222.122.195.5 即可浏览 Naver 网站主页。但通常输入 Naver 网站的域名 www.naver.com 访问网站。二者之间究竟有何区别? 没有区别,但接入过程不同。域名是赋予服务器端的虚拟地址,而非实际地址。因此,需要将虚拟地址转化为实际地址。那如何将域名变为 IP 地址呢?DNS 服务器担此重任,可以向 DNS 服务器请求转换地址。

IP 地址发生改变比域名发生改变的可能性更大。

让我们来解析一下下面这一段: http://mail.163.com/index.html

  1. http://:这个是协议,也就是 HTTP 超文本传输协议,也就是网页在网上传输的协议。

  2. mail:这个是服务器名,代表着是一个邮箱服务器,所以是 mail.

  3. 163.com:这个是域名,是用来定位网站的独一无二的名字。

  4. mail.163.com:这个是网站名,由服务器名+域名组成。

  5. /:这个是根目录,也就是说,通过网站名找到服务器,然后在服务器存放网页的根目录

  6. index.html:这个是根目录下的默认网页(当然,163 的默认网页是不是这个我不知道,只是大部分的默认网页,都是 index.html)

  7. http://mail.163.com/index.html:这个叫做 URL,统一资源定位符,全球性地址,用于定位网上的资源。

DNS 获取 IP 地址

上图展示了默认 DNS 服务器无法解析主机询问的域名 IP 地址时的应答过程。可以看出,默认 DNS 服务器收到自己无法解析的请求时,向上级 DNS 服务器询问。通过这种方式逐级向上传递信息,到达顶级 DNS 服务器—根 DNS 服务器时,它知道该向哪个 DNS 服务器询问。向下级 DNS 传递解析请求,得到 IP 地址后原路返回,最后将解析的 IP 地址传递到发起请求的主机。DNS 就是这样层次化管理的一种分布式数据库系统

利用域名获取 IP 地址

gethostbyname

#include <netdb.h>
struct hostent * gethostbyname(const char * hostname);

// 成功时返回 hostent 结构体地址,失败时返回 NULL指针。

hostent 结构体

struct hostent {
    char * h_name;       // 官方域名,代表某一主页
    char ** h_aliases;   // 可以通过多个域名访问同一主页。这些信息可以通过 h_aliases 获得。
    int h_addrtype;      // 地址族信息。 
    int h_length;        // 保存 IP 地址长度。若是 IPv4 地址,是 4 个字节;IPv6 时,是 16 个字节。
    char ** h_addr_list; // 最重要:以整数形式保存域名对应的 IP 地址。
}

域名转 IP 时只需关注 h_addr_list

利用 IP 地址获取域名

gethostbyaddr

#include <netdb.h>
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。

第九章:套接字的多种可选项

套接字的部分可选项

套接字相关事项:

IP 协议相关事项:

TCP 协议相关事项: 

查看可选项

getsockopt

#include <sys/socket.h>
int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);

// 成功时返回 0,失败时返回 -1。

// sock——用于查看选项套接字文件描述符。
// level——要查看的可选项的协议层。
// optname——要查看的可选项名。
// optval——保存查看结果的缓冲地址值。
// optlen——向第四个参数 optval 传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。

设置可选项

setsockopt

#include<Sys/socket.h>
int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);

// 成功时返回 0,失败时返回 -1。

// sock——用于更改可选项的套接字文件描述符。
// level——要更改的可选项协议层。
// optname——要更改的可选项名。
// optval——保存要更改的选项信息的缓冲地址值。
// optlen——向第四个参数 optval 传递的可选项信息的字节数。

I/O缓冲相关可选项

创建套接字将同时生成 I/O 缓冲。

SO_RCVBUF 、SO_SNDBUF

SO_RCVBUF 是输入缓冲大小相关可选项,SO_SNDBUF 是输出缓冲大小相关可选项。用这 2 个可选项既可以读取当前 I/O 缓冲大小,也可以进行更改。

SO_REUSEADDR

Time-wait 状态

再一次来说说四次挥手。

之前这样说的:

  1. TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传输时直接断掉连接会出问题,所以断开连接时需要双方协商。断开连接时双方对话如下:

    四次握手

    套接字A∶"我希望断开连接。"

    套接字B∶"哦,是吗?请稍候。"

    套接字B∶"我也准备就绪,可以断开连接。"

    套接字A∶"好的,谢谢合作。"

FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过程经历 4 个阶段,因此又称四次握手。图中向主机 A 传递了两次 ACK 5001,也许这会让各位感到困惑。其实,第二次 FIN 数据包中的 ACK 5001 只是因为接收 ACK 消息后未接收数据而重传的。

其实 A 发送完最后的 SEQ 5001 ACK 7502 后会进入 Time-wait 状态。

假设主机 A 是服务器端,因为是主机 A 向 B 发送 FIN 消息,故可以想象成服务器端在控制台输入 CTRL+C。但问题是,套接字经过四次握手过程后并非立即消除,而是要经过一段时间的 Time-wait 状态。当然,只有先断开连接的(先发送FIN消息的)主机才经过Time-wait状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处在Time-wait过程时,相应端口是正在使用的状态。因此,就像之前验证过的,bind 函数调用过程中当然会发生错误。

不管是服务器端还是客户端,套接字都会有 Time-wait 过程。先断开连接的套接字必然会经过 Time-wait 过程。但无需考虑客户端 Time-wait 状态。因为客户端套接字的端口号是任意指定的

到底为什么会有 Time-wait 状态呢?假设主机 A 向主机 B 传输 ACK 消息(SEO5001、ACK7502)后立即消除套接字。但最后这条 ACK 消息在传递途中丢失,未能传给主机 B。这时会发生什么? 主机 B 会认为之前自己发送的 FIN消息(SEO7501、ACK5001)未能抵达主机 A,继而试图重传。但此时主机 A 已是完全终止的状态,因此主机 B 永远无法收到从主机 A 最后传来的 ACK 消息。相反,若主机 A 的套接字处在 Time-wait 状态,则会向主机 B 重传最后的 ACK 消息。主机 B 也可以正常终止。基于这些考虑,先传输 FIN 消息的主机应经过 Time-wait 过程。

地址再分配/端口复用

查看网络相关信息的命令

netstat

参数:

-a:所有的socket

-p:显示正在使用socket的程序的名称

-n:直接使用 IP 地址,而不通过域名服务器

可以这么使用:netstat -anp | grep 端口号

考虑一下系统发生故障从而紧急停止的情况。这时需要尽快重启服务器端以提供服务,但因处于 Time-wait 状态而必须等待几分钟。

在主机 A 的四次握手过程中,如果最后的数据丢失,则主机 B 会认为主机 A 未能收到自己发送的 FIN 消息,因此重传。这时,收到 FIN 消息的主机 A 将重启 Time-wait 计时器。因此,如果网络状况不理想,Time-wait 状态将持续。

解决方案就是在套接字的可选项中更改 SO_REUSEADDR 的状态。SO_REUSEADDR的默认值为 0(假),这就意味着无法分配 Time-wait 状态下的套接字端口号。因此需要将这个值改成 1(真)。具体做法在示例 reuseadr_eserver.c 中给出。

示例:

// reuseadr_eserver.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#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 <port>\n", argv[0]);
		exit(1);
	}
	
	serv_sock=socket(PF_INET, SOCK_STREAM, 0);
	if(serv_sock==-1)
		error_handling("socket() error");
    
	//加上这段********************************************************
	optlen=sizeof(option);
	option=TRUE;	
	setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &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);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

TCP_NODELAY

Nagle算法

防止因数据包过多而发生网络过载。

特点:只有收到前一数据的 ACK 消息时,Nagle 算法才发送下一数据。

TCP 套接字默认使用 Nagle 算法交换数据,因此最大限度地进行缓冲,直到收到 ACK。上图左侧正是这种情况。为了发送字符串"Nagle",将其传递到输出缓冲。这时头字符"N"之前没有其他数据(没有需接收的ACK),因此立即传输。之后开始等待字符"N"的 ACK 消息,等待过程中,剩下的"agle"填入输出缓冲。接下来,收到字符"N"的 ACK 消息后,将输出缓冲的"agle"装入一个数据包发送。也就是说,共需传递 4 个数据包以传输 1 个字符串。

接下来分析未使用 Nagle 算法时发送字符串"Nagle"的过程。假设字符"N"到"e"依序传到输出缓冲。此时的发送过程与 ACK 接收与否无关,因此数据到达输出缓冲后将立即被发送出去。上图右侧可以看到,发送字符串"Nagle"时共需 10 个数据包。由此可知,不使用 Nagle 算法将对网络流量(Traffic∶指网络负载或混杂程度)产生负面影响。即使只传输 1 个字节的数据,其头信息都有可能是几十个字节。

因此,为了提高网络传输效率,必须使用 Nagle 算法。

但 Nagle 算法并不是什么时候都适用。根据传输数据的特性,网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快。最典型的是"传输大文件数据"。将文件数据传入输出缓冲不会花太多时间,因此,即便不使用 Nagle 算法,也会在装满输出缓冲时传输数据包。这不仅不会增加数据包的数量,反而会在无需等待 ACK 的前提下连续传输,因此可以大大提高传输速度。

一般情况下,不使用 Nagle 算法可以提高传输速度。但如果无条件放弃使用 Nagle 算法,就会增加过多的网络流量,反而会影响传输。

因此,未准确判断数据特性时不应禁用 Nagle 算法。

禁用 Nagle 算法

int opt_val = 1;

setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, sizeof(opt_val));

SO_RCVTIMEO、 SO_SNDTIMEO

分别用来设置 socket 接收数据超时时间和发送数据超时时间。因此,这两个选项仅对与数据接收和发送相关的 socket 专用系统调用有效,这些系统调用包括 send、sendmsg、recv、recvmsg、accept 和 connect。

在程序中,我们可以根据系统调用(send、sendmsg、recv、recvmsg、accept 和 connect)的返回值以及 errno 来判断超时时间是否已到,进而决定是否开始处理定时任务。

下面代码以 connect 为例,说明程序中如何使用 SO_SNDTIMEO 选项来定时。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int timeout_connect( const char* ip, int port, int time )
{
    int ret = 0;
    struct sockaddr_in address;
    bzero( &address, sizeof( address ) );
    address.sin_family = AF_INET;
    inet_pton( AF_INET, ip, &address.sin_addr );
    address.sin_port = htons( port );

    int sockfd = socket( PF_INET, SOCK_STREAM, 0 );
    assert( sockfd >= 0 );

    /*通过选项 SO_RCVTIMEO 和 SO_SNDTIMEO 所设置的超时时间的类型是 timeval,这和 select 系统调用的超时参数类型相同*/
    struct timeval timeout;
    timeout.tv_sec = time;
    timeout.tv_usec = 0;
    socklen_t len = sizeof( timeout );
    ret = setsockopt( sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len );
    assert( ret != -1 );

    ret = connect( sockfd, ( struct sockaddr* )&address, sizeof( address ) );
    if ( ret == -1 )
    {
        /*超时对应的错误号是 EINPROGRESS。下面这个条件如果成立,我们就可以处理定时任务了*/
        if( errno == EINPROGRESS )   // 不用定义,在头文件中。
        {
            printf( "connecting timeout\n" );
            return -1;
        }
        printf( "error occur when connecting to server\n" );
        return -1;
    }

    return sockfd;
}

int main( int argc, char* argv[] )
{
    if( argc <= 2 )
    {
        printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
        return 1;
    }
    const char* ip = argv[1];
    int port = atoi( argv[2] );

    int sockfd = timeout_connect( ip, port, 10 );
    if ( sockfd < 0 )
    {
        return 1;
    }
    return 0;
}

第十章:多进程服务器端

下面列出的是具有代表性的并发服务器端实现模型和方法:

  1. 多进程服务器:通过创建多个进程提供服务。

  2. 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务。

  3. 多线程服务器:通过生成与客户端等量的线程提供服务。

进程:用内存空间的正在运行的程序。

无论进程是如何创建的,所有进程都会从操作系统分配到 ID。此 ID 称为"进程 ID",其值为大于 2 的整数。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户进程无法得到 ID 值 1。

ps au 可查看进程。

fork(关于进程数量的问题)

#include <unistd.h>
pid_t fork(void);

// 成功时返回进程 ID,失败时返回 -1。

fork 函数将创建调用的进程副本。复制正在运行的、调用 fork 函数的进程。两个进程都将执行 fork 函数调用后的语句(准确地说是在 fork 函数返回后)。但因为通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。

getpid()     // 返回当前进程的值
getppid()    // 返回当前进程的父进程的值

  • 父进程:fork 函数返回子进程 ID。

  • 子进程:fork 函数返回 0。

父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回类型区分父子进程。父进程将 lval 的值加 1,但这不会影响子进程的 lval 值。同样,子进程将 gval 的值加 1 也不会影响到父进程的 gval。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一代码而已。

接下来给出示例验证之前的内容。

#include <stdio.h>
#include <unistd.h>
int gval=10;

int main(int argc, char *argv[])
{
    /*如果加上这一行会怎样?  
    printf("start \n");
    */
    pid_t pid;
    int lval=20;
    gval++, lval+=5;

    pid=fork();		
    if(pid==0)	     // if Child Process
        gval+=2, lval+=2;
    else			// if Parent Process
        gval-=2, lval-=2;

    if(pid==0)
        printf("Child Proc: [%d, %d] \n", gval, lval);
    else
        printf("Parent Proc: [%d, %d] \n", gval, lval);
    /*如果加上这一行会怎样?  
    printf("[%d, %d] \n", gval, lval);
    */
    return 0;
}


/*
Child Proc:[13,27]
Parent Proc:[9,23]
*/

/*
如果加上那两行,将输出:
start 
Parent Proc: [9, 23] 
[9, 23] 
Child Proc: [13, 27] 
[13, 27] 
*/
#include <stdio.h>
#include <unistd.h>

int main(){
    pid_t pid;
    printf("%d : aaaaa\n",getpid());
    pid = fork();
    printf("%d : bbbbb\n",getpid());
    pid = fork();
    printf("%d : ccccc\n",getpid());

    sleep(100);
    return 0;
}
/*  输出结果:
13429 : aaaaa
13429 : bbbbb
13429 : ccccc
13430 : bbbbb
13431 : ccccc
13430 : ccccc
13432 : ccccc
*/


#include <stdio.h>
#include <unistd.h>

int main(){
    pid_t pid;
    printf("%d : aaaaa",getpid());   // 没有"\n",缓冲区没有清空,子进程还可以继承"aaaaa"
    pid = fork();
    if(pid == 0){}
    printf("%d : bbbbb\n",getpid());
    sleep(100);
    return 0;
}
/*  输出结果:
14314 : aaaaa14314 : bbbbb
14314 : aaaaa14315 : bbbbb

printf("%d : aaaaa",getpid());没有刷新数据,缓冲区里还有"14314 : aaaaa",子进程在执行printf("%d : bbbbb\n",getpid());的时候一起打印出来。
而父进程是先打印"14314 : aaaaa",再打印"14314 : bbbbb"。

*/
#include <stdio.h>
#include <unistd.h>

int main(){
    pid_t pid;
    printf("aaaaa");   // 没有"\n",缓冲区没有清空,子进程还可以继承"aaaaa"
    pid = fork();
    //printf("bbbb\n");
    sleep(1);
    return 0;
}
/*  输出结果:
aaaaaaaaaaZhu@Zhu-virtual-machine:~/桌面/4.21$

子进程继承了缓冲区里的"aaaaa",子进程结束后,在屏幕上打印出来。
*/



#include <stdio.h>
#include <unistd.h>

int main(){
    pid_t pid;
    printf("aaaaa\n");   // 没有"\n",缓冲区没有清空,子进程还可以继承"aaaaa"
    pid = fork();
    //printf("bbbb\n");
    sleep(1);
    return 0;
}
/*  输出结果:
aaaaaa

缓冲区里的"aaaaa"被刷新,子进程无法继承。而子进程又是从fork后的代码开始运行,并不是从#开始运行,因此无法执行printf("aaaaa\n");这一句。也就无法打印任何数据。
因此只有父进程打印"aaaaa"。
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    
    int num = 0;
    int count = 10;
    pid_t pid;
    for(int i = 0; i < 3; i++){
        printf("num : %d, %d\n",num++, getpid());
        pid = fork();
        if(pid == 0){
            sleep(10);
            exit(0);
        }
        printf("count : %d, %d\n",count++, getpid());
    }
    printf("aaa, %d\n", getpid());
    sleep(10);
    return 0;
}
/*  输出结果:
num : 0, 13901
count : 10, 13901
num : 1, 13901
count : 11, 13901
num : 2, 13901
count : 12, 13901
aaa, 13901
*/
 
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{    
    int num = 0;
    int count = 10;
    pid_t pid;
    for(int i = 0; i < 3; i++){
        printf("num : %d, %d\n",num++, getpid());
        pid = fork();
        //if(pid == 0){
        //    sleep(10);
        //    exit(0);
        //}
        printf("count : %d, %d\n",count++, getpid());
    }
    printf("aaa, %d\n", getpid());
    sleep(10);
    return 0;
}
/*  输出结果:
num : 0, 14138
count : 10, 14138
num : 1, 14138
count : 10, 14139
num : 1, 14139
count : 11, 14138
num : 2, 14138
count : 11, 14139
num : 2, 14139
count : 12, 14138
aaa, 14138
count : 12, 14139
aaa, 14139
count : 11, 14140
num : 2, 14140
count : 12, 14140
aaa, 14140
count : 12, 14143
aaa, 14143
count : 12, 14144
aaa, 14144
count : 12, 14142
aaa, 14142
count : 11, 14141
num : 2, 14141
count : 12, 14141
aaa, 14141
count : 12, 14145
aaa, 14145
*/


案例:创建 5 个子进程,这 5 个子进程之间是并列的。

// 创建5个子进程
for(int i = 0; i < 5; i++) {
    pid = fork();
    if(pid == 0) {
        break;                   // 注意这里要break,即子进程跳出循环不再产生孙子进程。
    }
}

僵尸进程

对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态。另外一种使子进程进入僵尸态的情况是:父进程结束或者异常终止,而子进程继续运行。此时子进程的 PPID 将被操作系统设置为 1,即 init 进程。init 进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。

创建僵尸进程

#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	pid_t pid=fork();
	
	if(pid==0)     // if Child Process
	{
		puts("Hi I'am a child process");
	}
	else
	{
        // 输出子进程ID。可以通过该值查看子进程状态(是否为僵尸进程)。
		printf("Child Process ID: %d \n", pid); 
        // 父进程暂停30秒。如果父进程终止,处干僵尸状态的子进程将同时销毁。因此,延缓父进程的执行以验证僵尸进程。
		sleep(30);     
	}

	if(pid==0)
		puts("End child process");
	else
		puts("End parent process");
	return 0;
}


/*
Hi,I am a child process 
End child process 
Child Process ID:10977
*/

再在终端(可以是运行上面程序的同一个终端)输入 ps au 查看僵尸进程。在 state 栏里为 Z+ 的就是僵尸进程。

销毁僵尸进程 1:wait

为了销毁子进程,父进程应主动请求获取子进程的返回值。

#include <sys/wait.h>

pid_t wait(int* status);

// status——传出类型的参数

// 成功时返回终止的子进程 ID,失败时返回 -1。

调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit 函数的参数值、main 函数的 return 返回值)将保存到该函数的参数所指内存空间。但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离。

  1. WIFEXITED 子进程正常终止时返回"真"(true )。(wait if exited)

  2. WEXITSTATUS 返回子进程的返回值。(wait exit status)

也就是说,向 wait 函数传递变量 status 的地址时,调用 wait 函数后应编写如下代码。

if(WIFEXITED(status))                                 // 是正常终止的吗?
{
    puts("Normal termination!");
    printf("Child pass num∶%d",WEXITSTATUS(status)); // 那么返回值是多少?
}

根据上述内容编写示例,此示例中不会再让子进程变成僵尸进程。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

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);   // 上面的子进程将通过这行终止。
		}
		else
		{
			printf("Child PID: %d \n", pid);
            
            // 调用wait函数。之前终止的子进程相关信息将保存到status变量,同时相关子进程被完全销毁。
			wait(&status);
            // 验证子进程是否正常终止。如果正常退出,则调用WEXITSTATUS输出子进程返回值。
			if(WIFEXITED(status))
				printf("Child send one: %d \n", WEXITSTATUS(status));

            // 因为之前创建了2个进程,所以再次调用wait函数和宏。
			wait(&status);
			if(WIFEXITED(status))
				printf("Child send two: %d \n", WEXITSTATUS(status));
            
			sleep(30);     // 为暂停父进程终止而插入的代码。此时可以查看子进程的状态。
		}
	}
	return 0;
}

调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止。

销毁僵尸进程 2:waitpid

非阻塞。

#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* status, int options);

// 成功时返回终止的子进程 ID(或0,是否返回0看最后一个参数),失败时返回 -1。

// pid——等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止。
// status——与wait函数的status参数具有相同含义。
// options——传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
	int status;
	pid_t pid=fork();
	
	if(pid==0)
	{
		sleep(15);
		return 24;   	
	}
	else
	{
        // while循环中调用waitpid函数。向第三个参数传递WNOHANG,因此,若之前没有终止的子进程将返回0。
		while(!waitpid(-1, &status, WNOHANG))
		{
			sleep(3);
			puts("sleep 3sec.");
		}

		if(WIFEXITED(status))
			printf("Child send %d \n", WEXITSTATUS(status));
	}
	return 0;
}

/*
root@my_linux:/home/swyoon/tcpip# gcc waitpid.c -o waitpi
root@my_linux:/home/swyoon/tcpip# ./waitpid
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
Child send 24 
*/

信号处理机制

子进程终止的识别主体是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,将有助于构建高效的程序。

函数指针

int Func(int x);   /*声明一个函数*/
int (*p) (int x);  /*定义一个函数指针*/
p = Func;          /*将Func函数的首地址赋给指针变量p*/
int c = (*p)(2);   /*使用函数指针*/

进程∶"嘿,操作系统!如果我之前创建的子进程终止,就帮我调用 zombie handler 函数。"

操作系统∶"好的!如果你的子进程终止,我会帮你调用 zombie handler 函数,你先把该函数要执行的语句编好!"

上述对话中进程所讲的相当于"注册信号"过程,即进程发现自己的子进程结束时,请求操作系统调用特定函数。该请求通过如下函数调用完成(因此称此函数为信号注册函数)。

信号处理函数signal

#include <signal.h>
void(*signal(int signo,void(*func)(int)))(int);

// 为了在产生信号时调用,返回之前注册的函数指针。

// 函数名:signal
// 参数:int signo,void(*func)(int)
// 返回类型:参数为int型,返回void型函数指针。

调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在 signal 函数中注册的部分特殊情况和对应的常数:

  1. SIGALRM:已到通过调用 alarm 函数注册的时间。

  2. SIGINT:输入 CTRL+C。

  3. SIGCHLD:子进程终止。

注册信号,调用 signal 函数:

  1. 子进程终止则调用 mychild 函数。

    signal(SIGCHLD, mychild);
  2. 已到通过 alarm 函数注册的时间,请调用 timeout 函数。

    signal(SIGALRM, timeout);
  3. 输入 CTRL+C 时调用 keycontrol 函数。

    signal(SIGINT, keycontrol);

注册的情况发生时,操作系统调用相应的函数。

被调用的函数只能传入信号量作为参数。

alarm 函数

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

// 返回 0 或以秒为单位的距 SIGALRM 信号发生所剩时间。
  1. 如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。

  2. 若向该函数传递 0,则之前对 SIGALRM 信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)  // 这种类型的函数称为信号处理器(Handler)。
{
	if(sig==SIGALRM)
		puts("Time out!");

	alarm(2);	// 为了每隔2秒重复产生SIGALRM信号,在信号处理器中调用alarm函数。
}
void keycontrol(int sig)
{
	if(sig==SIGINT)
		puts("CTRL+C pressed");
}

int main(int argc, char *argv[])
{
	int i;
	signal(SIGALRM, timeout);     // 注册SIGALRM、SIGINT信号及相应处理器。
	signal(SIGINT, keycontrol);   // 终端输入 Ctrl+C
	alarm(2);                     // 预约2秒后发生SIGALRM信号。

	for(i=0; i<3; i++)
	{
		puts("wait...");
        // 为了查看信号产生和信号处理器的执行并提供每次100秒、共3次的等待时间,在循环中调用sleep函数。也就是说,再过300秒、约5分钟后终止程序,这是相当长的一段时间,但实际执行时只需不到10秒。关于其原因稍后再解释。
		sleep(100);
	}
	return 0;
}

/*
root@my_linux:/tcpip# gcc signal.c -o signal 
root@my_linux:/tcpip# ./signal 
wait... 
Time out!
wait... 
Time out!
wait... 
Time out!
*/

上述是没有任何输入时的运行结果。如果在运行过程中输入 CTRL+C,可以看到输出"CTRL+C pressed"字符串。有一点必须说明:"发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程。"

调用函数的主体的确是操作系统,但进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 函数中规定的时间也是如此。所以,上述示例运行不到 10 秒就会结束,连续输入 CTRL+C 则有可能 1 秒都不到。

信号处理函数sigaction

signal 函数在 UNIX 系列的不同操作系统中可能存在区别,但 sigaction 函数完全相同。

#include <signal.h>
int sigaction(int signo, const struct sigaction* act, struct sigaction* oldact);

// 成功时返回 0,失败时返回 -1。

// signo——与signal函数相同,传递信号信息。
// act——对应于第一个参数的信号处理函数(信号处理器)信息。
// oldact——通过此参数获取之前注册的信号处理函数指针,若不需要则传递0。

// 注意:函数名和参数列表里的结构体名字一样。
struct sigaction{ 
    void(*sa_handler)(int);   // 保存信号处理函数的指针值(地址值)。
    
    // 所有位均初始化为0即可。这2个成员指定信号相关的选项和特性,而我们的目的主要是防止产生僵尸进程,故省略。
    sigset_t sa_mask;   
    int sa_flags;
}

示例

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)
{
	if(sig==SIGALRM)
		puts("Time out!");
	alarm(2);	
}

int main(int argc, char *argv[])
{
	int i;
    
    // 为了注册信号处理函数,声明sigaction结构体变量并在sa_handler成员中保存函数指针值。
	struct sigaction act;
	act.sa_handler=timeout;
    
	sigemptyset(&act.sa_mask);  // 调用sigemptyset函数将sa_mask成员的所有位初始化为0。
	act.sa_flags=0;             // sa_flags成员同样初始化为0。
	sigaction(SIGALRM, &act, 0);

	alarm(2);

	for(i=0; i<3; i++)
	{
		puts("wait...");
		sleep(100);
	}
	return 0;
}

/*
root@my_linux:/tcpip# gcc sigaction.c -o sigaction 
root@my_linux:/tcpip# ./sigaction
wait... 
Time out!
wait... 
Time out!
wait... 
Time out!
*/
 

利用信号处理技术消灭僵尸进程

子进程终止时将产生 SIGCHLD 信号。接下来利用 sigaction 函数编写示例。

 
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void read_childproc(int sig)
{
    int status;

    // 处理函数中调用了waitpid函数,所以子进程将正常终止,不会成为僵尸进程。
    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;

    // 注册SIGCHLD信号对应的处理器。就是这么写的,都这样。
    struct sigaction act;
    act.sa_handler=read_childproc;   // 需要调用的函数
    sigemptyset(&act.sa_mask);       // 结构体不相关的置零
    act.sa_flags=0;                  // 结构体不相关的置零
    sigaction(SIGCHLD, &act, 0);     // 注册处理器

    pid=fork();
    if(pid==0)
    {
        puts("Hi! I'm child process");
        sleep(10);
        return 12;
    }
    else
    {
        printf("Child proc id: %d \n", pid);
        pid=fork();                          // 无论如何都是这个子进程先结束。
        if(pid==0)
        {
            puts("Hi! I'm child process");
            sleep(10);
            exit(24);
        }
        else
        {
            int i;
            printf("Child proc id: %d \n", pid);

            // 为了等待发生SIGCHLD信号,使父进程共暂停5次,每次间隔5秒。发生信号时,父进程将被唤醒,因此实际暂停时间不到25秒。
            for(i=0; i<5; i++)
            {
                puts("wait...");
                sleep(5);
            }
        }
    }
    return 0;
}

/*
root@my_linux:/home/swyoon/tcpip# gcc remove_zombie.c -o zombie
root@my_linux:/home/swyoon/tcpip# ./zombie
Hi! I'm child process
Child proc id: 9529 
Hi! I'm child process
Child proc id: 9530 
wait...
wait...
Removed proc id: 9530 
Child send: 24 
wait...
Removed proc id: 9529 
Child send: 12 
wait...
wait...
*/


基于多任务的并发服务器

基于多进程的并发回声服务器端的实现模型:

  1. 第一阶段:回声服务器端(父进程)通过调用 accept 函数受理连接请求。

  2. 第二阶段:此时获取的套接字文件描述符创建并传递给子进程。

  3. 第三阶段:子进程利用传递来的文件描述符提供服务。

服务器端:

// echo_mpserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#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 <port>\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)  // accept函数在while里面
	{
		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==-1)
		{
			close(clnt_sock);
			continue;
		}
		if(pid==0)   // 进入子进程。
		{
            // 疑点1
            // 关闭 serv_sock=socket(PF_INET, SOCK_STREAM, 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
            // 疑点2
        // 通过accept函数创建的套接字文件描述符已复制给子进程,因此服务器端需要销毁自己拥有的文件描述符。
			close(clnt_sock);
	}
	close(serv_sock);
	return 0;
}

void read_childproc(int sig)
{
	pid_t pid;
	int status;
	pid=waitpid(-1, &status, WNOHANG);  // 第一个参数为-1,等待任意进程结束。
	printf("removed proc id: %d \n", pid);
}
void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

客户端:

// echo_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#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 <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	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]));
	
	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"))  // 相等返回0
		{	
			shutdown(sock, SHUT_WR);
			return;
		}
		write(sock, buf, strlen(buf));
	}
}
void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

启动服务器端后,要创建多个客户端并建立连接。

关于服务器端的两个疑点

父进程是将套接字的文件描述符复制给子进程,而不是把套接字复制给子进程。

解释:调用 fork 函数时复制父进程的所有资源。但套接字并非进程所有。从严格意义上说,套接字属于操作系统,只是进程拥有代表相应套接字的文件描述符

另一种理解:

调用 fork 函数后,两个文件描述符指向同一套接字。

1 个套接字中存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的连接状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法完全销毁套接字(服务器端套接字同样如此)。

说明套接字只有一个文件描述符时,销毁文件描述符就可以销毁套接字。

因此,调用 fork 函数后,要将无关的套接字文件描述符关掉。如下图所示。

分割TCP的I/O程序

  • 之前:向服务器端传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。

  • 现在:客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输入和输出,这样,无论客户端是否从服务器端接收完数据都可以进行传输。

  • 对比:

客户端代码:(服务器端、客户端的代码和上面的那段一样)

// echo_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#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 <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	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]));
	
	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"))  // 相等返回0
		{	
			shutdown(sock, SHUT_WR);
			return;
		}
		write(sock, buf, strlen(buf));
	}
}
void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

进程里close与shutdown的区别

①close 函数函数会关闭套接字,如果由其他进程共享着这个套接字,那么它仍然是打开的,这个连接仍然可以用来读和写。

②shutdown 会切断进程共享的套接字的所有连接,不管引用计数是否为 0,由第二个参数选择断连的方式。

第十一章:进程间通信(并发服务器)

只要有两个进程可以同时访问的内存空间,就可以通过此空间交换数据。但进程具有完全独立的内存结构。就连通过 fork 函数创建的子进程也不会与父进程共享内存空间。因此,进程间通信只能通过其他特殊方法完成。

管道

管道不是进程的资源,属于操作系统。

pipe

#include <unistd.h>
int pipe(int filedes[2]);

// 成功时返回 0,失败时返回 -1。

// filedes[0]——通过管道接收数据时使用的文件描述符,即管道出口。
// filedes[1]——通过管道传输数据时使用的文件描述符,即管道入口。

示例:

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
	int fds[2];
	char str[]="Who are you?";
	char buf[BUF_SIZE];
	pid_t pid;
	
	pipe(fds);
	pid=fork();  // 复制的并非管道,而是用于管道I/O的文件描述符。至此,父子进程同时拥有I/O文件描述符。
	if(pid==0)
	{
		write(fds[1], str, sizeof(str));
	}
	else
	{
		read(fds[0], buf, BUF_SIZE);
		puts(buf);
	}
	return 0;
}

进程间双向通信

一个管道:

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
	int fds[2];
	char str1[]="Who are you?";
	char str2[]="Thank you for your message";
	char buf[BUF_SIZE];
	pid_t pid;

	pipe(fds);
	pid=fork();

	if(pid==0)
	{
		write(fds[1], str1, sizeof(str1));
		sleep(2);      // 标记1
		read(fds[0], buf, BUF_SIZE);
		printf("Child proc output: %s \n",  buf);
	}
	else
	{
        // 位置1
		read(fds[0], buf, BUF_SIZE);
		printf("Parent proc output: %s \n", buf);
		write(fds[1], str2, sizeof(str2));
		sleep(3);      // 标记2
	}
	return 0;
}

/*
Parent proc output: Who are you?
Child proc output: Thank you for your message
*/

/*注释到标记2:
Parent proc output: Who are you?
控制台信息:Child proc output: Thank you for your message
(只有第二行前面有控制台信息)
*/

/*将标记1移动到位置1:
Child proc output: Who are you?
*/

将标记 1 移动到位置 1 后的结果的原因:

数据进入管道后成为无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。

两个管道:

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
	int fds1[2], fds2[2];
	char str1[]="Who are you?";
	char str2[]="Thank you for your message";
	char buf[BUF_SIZE];
	pid_t pid;
	
	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;
}

运用进程间通信

// echo storeserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 100
void error_handling(char *message);
void read_childproc(int sig);

char buf[BUF_SIZE];

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;
	if(argc!=2) {
		printf("Usage : %s <port>\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");
	
	pipe(fds);   // 创建管道。
	pid=fork();  // 创建进程。
	if(pid==0)
	{
		FILE * fp=fopen("echomsg.txt", "wt");
		char msgbuf[BUF_SIZE];
		int i, len;

        // 服务器端并不终止运行,而是不断向客户端提供服务。因此,数据在文件中累计到一定程度即关闭文件,该过程通过下面的循环完成。
		for(i=0; i<10; i++)  // 一次性写入10个字符串
		{
			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...");

        // 通过fork函数创建的所有子进程将复制上面创建的管道的文件描述符。因此,可以通过管道入口fds[1]传递字符串信息。
		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);
			}
			
			close(clnt_sock);
			puts("client disconnected...");
			return 0;
		}
		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(buf, stderr);
	fputc('\n', stderr);
	exit(1);
}
// echo_mpclient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#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 <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	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]));
	
	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);
}

第十二章:I/O复用(并发服务器)

为了构建并发服务器,只要有客户端连接请求就会创建新进程。但是创建进程时需要付出极大代价。这需要大量的运算和内存空间。由于每个进程都具有独立的内存空间,所以相互间的数据交换也要求采用相对复杂的方法(IPC(进程间通信) 属于相对复杂的通信方法)。

多进程服务器:

一个老师回答一个学生的提问。

I/O 复用服务器:

一个老师回答一个班的学生的轮流提问。

select

使用 select 函数时可以将多个文件描述符集中到一起统一监视,项目如下:

  1. 是否存在套接字接收数据?

  2. 无需阻塞传输数据的套接字有哪些?

  3. 哪些套接字发生了异常?

调用过程

设置文件描述符

利用 select 函数可以同时监视多个文件描述符。监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中到一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述 3 种监视项分成 3 类。

使用 fd_set 数组变量执行此项操作。该数组是存有 0 和 1 的位数组。该数组有 128 个 long 型元素,每个元素 8字节,一共 1024 字节,一共可以监听 1024 个文件描述符。所有不用定义 fd_set 型变量的大小。

如果该位设置为 1,则表示该文件描述符是监视对象。文件描述符 1 和 3 是监视对象。

在 fd_set 变量中注册或更改值的操作都由下列宏完成:

  1. FD_ZERO(fd_set* fdset):将 fd_set 变量的所有位初始化为 0。

  2. FD_SET(int fd,fd_set* fdset):在参数 fdset 指向的变量中注册(置 1)文件描述符 fd 的信息。

  3. FD_CLR(int fd,fd_set* fdset):从参数 fdset 指向的变量中清除文件描述符 fd 的信息。

  4. FD_ISSET(int fd,fd_set* fdset):若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回"真"。

设置检查(监视)范围及超时(timeout)

#include <sys/select.h>
#include <sys/time.h>
int select (int maxfd, fd_set* readset, fd_set* writeset, fd_set* exceptset, const struct timeval* timeout);

// 发生错误时返回 -1,超时返回时返回 0。因发生关注的事件返回时,返回大于 0 的值,该值是发生事件的文件描述符数量。

// maxfd——监视对象文件描述符数量。
// readset——将所有关注"是否存在待读取数据"的文件描述符注册到fd_set型变量,并传递其地址值。
// writeset——将所有关注"是否可传输无阻塞数据"的文件描述符注册到fd_set型变量,并传递其地址值。
// exceptset——将所有关注"是否发生异常"的文件描述符注册到fd_set型变量,并传递其地址值。
// timeout——调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。

调用 select 函数之前需要做的事:

  1. 文件描述符的监视(检查)范围是?

    文件描述符的监视范围与 select 函数的第一个参数有关。实际上,select 函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在 fd_set 变量中的文件描述符数。但每次新建文件描述符时,其值都会增 1,故只需将最大的文件描述符值加 1 再传递到 select 函数即可。加 1 是因为文件描述符的值从 0 开始。

  2. 如何设定 select 函数的超时时间?

    select 函数的超时时间与 select 函数的最后一个参数有关,其中 timeval 结构体定义如下。

    struct timeval{
        long tv_sec;   // seconds
        long tv_usec;  // microseconds
    }

    本来 select 函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。这种情况下,select 函数返回 0。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递 NULL 参数,即永久阻塞

调用 select 函数后查看结果

想监测 fd1、fd2、fd3,就把对应的三位置 1.

检测到 fd1和 fd3 有数据,相应的位置还是置 1。fd2 没有检测到有数据,相应的地方置零

select 函数示例

// select.c

#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>

#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;  // 定义 select 的最后一个参数。

	FD_ZERO(&reads);    // 初始化fd_set变量
	FD_SET(0, &reads);  // 将文件描述符0对应的位设置为1。换言之,需要监视标准输入的变化。

	/*
	// 这是为了设置select函数的超时而添加的。但不能在此时设置超时。因为调用select函数后,结构体timeval的成员tv_sec和tv_usec的值将被替换为超时前剩余时间。因此,调用select函数前,每次都需要初始化timeval结构体变量。
	timeout.tv_sec=5;
	timeout.tv_usec=5000;
	*/

	while(1)
	{
        // 将准备好的fd_set变量reads的内容复制到temps变量,因为之前讲过,调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须经过这种复制过程。这是使用select函数的通用方法。
		temps=reads;
        
        // 将初始化timeval结构体的代码插入循环后,每次调用select函数前都会初始化新值。
		timeout.tv_sec=5;
		timeout.tv_usec=0;
        
        // 调用select函数。如果有控制台输入数据,则返回大于O的整数;如果没有输入数据而引发超时,则返回0。
		result=select(1, &temps, 0, 0, &timeout);  // 返回变化的文件描述符数量。
		if(result==-1)
		{
			puts("select() error!");
			break;
		}
		else if(result==0)
		{
			puts("Time-out!");
		}
		else 
		{
            // select函数返回大于0的值时运行的区域。验证发生变化的文件描述符是否为标准输入。若是,则从标准输入读取数据并向控制台输出。
			if(FD_ISSET(0, &temps))  // 标准输入的文件描述符是 0。
			{
				str_len=read(0, buf, BUF_SIZE); // 0 是标准输入流。
				buf[str_len]=0;
				printf("message from console: %s", buf);
			}
		}
	}
	return 0;
}


/*
root@my_linux:/tcpip# gcc select.c-o select 
root@my_linux:/tcpip# ./select 
Hi
message from console: Hi~
Hello~
message from console:Hello~
Time-out!
Time-out!
Good bye~
message from console: Good bye~
*/

运行后若无任何输入,经 5 秒将发生超时。若通过键盘输入字符串,则可看到相同字符串输出。

实现 I/O 复用

// select.c

#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>

#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;  // 定义 select 的最后一个参数。

	FD_ZERO(&reads);    // 初始化fd_set变量
	FD_SET(0, &reads);  // 将文件描述符0对应的位设置为1。换言之,需要监视标准输入的变化。

	/*
	// 这是为了设置select函数的超时而添加的。但不能在此时设置超时。因为调用select函数后,结构体timeval的成员tv_sec和tv_usec的值将被替换为超时前剩余时间。因此,调用select函数前,每次都需要初始化timeval结构体变量。
	timeout.tv_sec=5;
	timeout.tv_usec=5000;
	*/

	while(1)
	{
        // 将准备好的fd_set变量reads的内容复制到temps变量,因为之前讲过,调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须经过这种复制过程。这是使用select函数的通用方法。
		temps=reads;
        
        // 将初始化timeval结构体的代码插入循环后,每次调用select函数前都会初始化新值。
		timeout.tv_sec=5;
		timeout.tv_usec=0;
        
        // 调用select函数。如果有控制台输入数据,则返回大于O的整数;如果没有输入数据而引发超时,则返回0。
		result=select(1, &temps, 0, 0, &timeout);  // 返回变化的文件描述符数量。
		if(result==-1)
		{
			puts("select() error!");
			break;
		}
		else if(result==0)
		{
			puts("Time-out!");
		}
		else 
		{
            // select函数返回大于0的值时运行的区域。验证发生变化的文件描述符是否为标准输入。若是,则从标准输入读取数据并向控制台输出。
			if(FD_ISSET(0, &temps))  // 标准输入的文件描述符是 0。
			{
				str_len=read(0, buf, BUF_SIZE); // 0 是标准输入流。
				buf[str_len]=0;
				printf("message from console: %s", buf);
			}
		}
	}
	return 0;
}


/*
root@my_linux:/tcpip# gcc select.c-o select 
root@my_linux:/tcpip# ./select 
Hi
message from console: Hi~
Hello~
message from console:Hello~
Time-out!
Time-out!
Good bye~
message from console: Good bye~
*/

第十三章:多种I/O函数

send、recv

#include <sys/socket.h>
ssize_t send(int sockfd, const void * buf, size_t nbytes, int flags);

// 成功时返回发送的字节数,失败时返回 -1。

// sockfd——表示与数据传输对象的连接的套接字文件描述符。
// buf——保存待传输数据的缓冲地址值。
// nbytes——待传输的字节数。
// flags——传输数据时指定的可选项信息。

#include <sys/socket.h>
ssize_t recv(int sockfd,void * buf,size_t nbytes,int flags);

// 成功时返回接收的字节数(收到 EOF 时返回 0),失败时返回 -1。

// sockfd——表示数据接收对象的连接的套接字文件描述符。
// buf——保存接收数据的缓冲地址值。
// nbytes——可接收的最大字节数。
// flags——接收数据时指定的可选项信息。

readv、writev

对数据进行整合传输及发送的函数。

writev

#include <sys/uio.h>
ssize_t writev(int filedes, const struct iovec* iov, int iovcnt);

// 成功时返回发送的字节数,失败时返回 -1。

// filedes——表示数据传输对象的套接字文件描述符。但该函数并不只限于套接字,因此,可以像read函数一样向其传递文件或标准输出描述符。
// ioviovec——结构体数组的地址值,结构体iovec中包含待发送数据的位置和大小信息。
// iovcnt——向第二个参数传递的数组长度。
struct iovec{
    void * iov_base; // 缓冲地址
    size_t iov_len;  // 缓冲大小
}

#include <stdio.h>
#include <sys/uio.h>

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); // writev函数的第一个参数为1,故向控制台输出数据。
	puts("");
	printf("Write bytes: %d \n", str_len);
	return 0;
}

/*
ABC1234 
Write bytes:7
*/

readv

#include <sys/uio.h>
ssize_t readv(int filedes, const struct iovec * iov, int iovcnt);

// 成功时返回接收的字节数,失败时返回 -1。

// filedes——传递接收数据的文件(或套接字)描述符。
// iov——包含数据保存位置和大小信息的iovec结构体数组的地址值。
// iovcnt——第二个参数中数组的长度。
#include <stdio.h>
#include <sys/uio.h>
#define BUF_SIZE 100

int main(int argc, char *argv[])
{
	struct iovec vec[2];
	char buf1[BUF_SIZE]={0,};
	char buf2[BUF_SIZE]={0,};
	int str_len;

    // 设置第一个数据的保存位置和大小。接收数据的大小已指定为5,因此,无论buf1的大小是多少,最多仅能保存5个字节。
	vec[0].iov_base=buf1;
	vec[0].iov_len=5;
    
    // vec[0]中注册的缓冲中保存5个字节,剩余数据将保存到vec[1]中注册的缓冲。结构体iovec的成员iov_len中应写入接收的最大字节数。
	vec[1].iov_base=buf2;
	vec[1].iov_len=BUF_SIZE;

	str_len=readv(0, vec, 2);               // readv函数的第一个参数为0,因此从标准输入接收数据。
	printf("Read bytes: %d \n", str_len);
	printf("First message: %s \n", buf1);
	printf("Second message: %s \n", buf2);
	return 0;
}

/*
swyoon@my_linux:~/tcpip$ gcc readv.c -o rv
swyoon@my_linux:~/tcpip$ ./rv
I like TCP/IP socket programming~
Read bytes: 34 
First message: I lik 
Second message: e TCP/IP socket programming~
*/

合理使用 read、writev函数

需要传输的数据分别位于不同缓冲(数组)时,需要多次调用 write 函数。此时可以通过 1 次 writev 函数调用替代操作,当然会提高效率。同样,需要将输入缓冲中的数据读入不同位置时,可以不必多次调用 read 函数,而是利用 1 次 readv 函数就能大大提高效率。

即使仅从 C 语言角度看,减少函数调用次数也能相应提高性能。但其更大的意义在于减少数据包个数。假设为了提高效率而在服务器端明确阻止了 Nagle 算法。其实 writev 函数在不采用 Nagle 算法时更有价值。

Nagle算法关闭状态下的数据传输:

上述示例中待发送的数据分别存在 3 个不同的地方,此时如果使用 write 函数则需要 3 次函数调用。但若为提高速度而关闭了 Nagle 算法,则极有可能通过 3 个数据包传递数据。反之,若使用 writev 函数将所有数据一次性写入输出缓冲,则很有可能仅通过 1 个数据包传输数据。所以 writev 函数和 readv 函数非常有用。

sendfile

sendfile 函数在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这被称为零拷贝。sendfile 函数的定义如下:

#include <sys/sendfile. h>
ssize t sendfile(int out_fd, int in_fd, off_t* offset, size_t count );

// 成功时返回传输的字节数,失败则返回 -1 并设置 errno。

// out_fd——待写入内容的文件描述符。必须是一个socket。
// in_fd——待读出内容的文件描述符。必须指向真实的文件,不能是 socket 和管道。
// offset——指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置。
// count——传输的字节数。

示例:

// sendfil.cpp

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/sendfile.h>

int main( int argc, char* argv[] )
{
    if( argc <= 3 )
    {
        printf( "usage: %s ip_address port_number filename\n", basename( argv[0] ) );
        return 1;
    }
    const char* ip = argv[1];
    int port = atoi( argv[2] );
    const char* file_name = argv[3];

    int filefd = open( file_name, O_RDONLY );
    assert( filefd > 0 );        // 为TRUE往下运行,否则退出。
    struct stat stat_buf;
    fstat( filefd, &stat_buf );  // 将filefd的文件状态复制到stat_buf结构体中。

    struct sockaddr_in address;
    bzero( &address, sizeof( address ) );   // 清空。
    address.sin_family = AF_INET;
    inet_pton( AF_INET, ip, &address.sin_addr );
    address.sin_port = htons( port );

    int sock = socket( PF_INET, SOCK_STREAM, 0 );
    assert( sock >= 0 );

    int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
    assert( ret != -1 );

    ret = listen( sock, 5 );
    assert( ret != -1 );

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof( client );
    int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
    if ( connfd < 0 )
    {
        printf( "errno is: %d\n", errno );
    }
    else
    {
        sendfile( connfd, filefd, NULL, stat_buf.st_size );
        close( connfd );
    }

    close( sock );
    return 0;
}


/* 
运行服务器端
g++ sendfil.cpp -o sendfil
./sendfil 127.0.0.1 9190 a.txt

运行客户端
telnet 127.0.0.1 9190
*/

/*
运行结果,在客户端
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
aaaaaaaaa                               // a.txt里的内容
Connection closed by foreign host.
*/

第十四章:多播与广播

多播

多播方式的数据传输是基于 UDP 完成的。因此,与 UDP 服务器端/客户端的实现方式非常接近。区别在于,UDP 数据传输以单一目标进行,而多播数据同时传递到加入(注册)特定组的大量主机。换言之,采用多播方式时,可以同时向多个主机传递数据。

多播的数据传输特点可整理如下:

  1. 多播服务器端针对特定多播组,只发送 1 次数据。

  2. 即使只发送 1 次数据,但该组内的所有客户端都会接收数据。

  3. 多播组数可在 IP 地址范围内任意增加。

  4. 加入特定组即可接收发往该多播组的数据。

向网络传递 1 个 多播数据包时,路由器将复制该数据包并传递到多个主机。

路由(Routing)和TTL(Time to Live,生存时间),以及加入组的方法

TTL 用整数表示,并且每经过 1 个路由器就减 1。TTL 变为 0 时,该数据包无法再被传递,只能销毁。因此,TTL 的值设置过大将影响网络流量。当然,设置过小也会无法传递到目标。

设置 TTL:

程序中的 TTL 设置是通过第 9 章的套接字可选项完成的。与设置 TTL 相关的协议层为 IPPROTO_IP,选项名为 IP_MULTICAST_TTL。因此,可以用如下代码把 TTL 设置为 64。

int send_sock;
int time_live=64;
...
send_sock=socket(PF_INET, SOCK_DGRAM, O);
setsockopt(send_sock,IPPROTO_IP,IP_MULTICAST_TTL,(void*)&time_live, sizeof(time_live));
...

加入多播组:

加入多播组也通过设置套接字选项完成。加入多播组相关的协议层为 IPPROTO_IP,选项名为 IP_ADD_MEMBERSHIP。可通过如下代码加入多播组。

int recv_sock;
struct ip_mreq join_adr;
...
recv_sock=socket(PF_INET,SOCK_DGRAM, O);
...
join_adr.imr_multiaddr.s_addr="多播组地址信息";
join_adr.imr_interface.s_addr="加入多播组的主机地址信息";
setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)& join_adr, sizeof(join_adr));
...
struct ip_mreq{
    struct in_addr imr_multiaddr;  // 写入加入的组IP地址
    struct in_addr imr_interface;  // 加入该组的套接字所属主机的IP地址,也可使用INADDR_ANY
}

实现多播 Sender 和 Receiver

多播中用"发送者"(Sender)和"接受者"(Receiver)替代服务器端和客户端。

下面讨论即将给出的示例,该示例的运行场景如下:

Sender∶ 向 AAA 组广播(Broadcasting)文件中保存的新闻信息。

Receiver∶接收传递到 AAA 组的新闻信息。

// news_sender.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define TTL 64
#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])    // 和之前的不同,既要传IP地址,又要传端口号。
{
	int send_sock;
	struct sockaddr_in mul_adr;
	int time_live=TTL;
	FILE *fp;
	char buf[BUF_SIZE];

	if(argc!=3){
		printf("Usage : %s <GroupIP> <PORT>\n", argv[0]);
		exit(1);
	}
  	
	send_sock=socket(PF_INET, SOCK_DGRAM, 0);  // 多播数据通信是通过UDP完成的,因此创建UDP套接字。
	memset(&mul_adr, 0, sizeof(mul_adr));
	mul_adr.sin_family=AF_INET;
	mul_adr.sin_addr.s_addr=inet_addr(argv[1]);  // Multicast IP
	mul_adr.sin_port=htons(atoi(argv[2]));       // Multicast Port
	
    // 指定套接字TTL信息,这是Sender中的必要过程。
	setsockopt(send_sock, IPPROTO_IP, IP_MULTICAST_TTL, (void*)&time_live, sizeof(time_live));
	
	if((fp=fopen("news.txt", "r"))==NULL)
		error_handling("fopen() error");

	while(!feof(fp))   /* Broadcasting */
	{
		fgets(buf, BUF_SIZE, fp);
         // 实际传输数据的区域。基于UDP套接字传输数据,因此需要利用sendto函数。
		sendto(send_sock, buf, strlen(buf), 0, (struct sockaddr*)&mul_adr, sizeof(mul_adr));
		sleep(2);
	}
	fclose(fp);
	close(send_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}
// news_receiver.C

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int recv_sock;
	int str_len;
	char buf[BUF_SIZE];
	struct sockaddr_in adr;
	struct ip_mreq join_adr;
	
	if(argc!=3) {
		printf("Usage : %s <GroupIP> <PORT>\n", argv[0]);
		exit(1);
	 }
  
	recv_sock=socket(PF_INET, SOCK_DGRAM, 0);
 	memset(&adr, 0, sizeof(adr));
	adr.sin_family=AF_INET;
	adr.sin_addr.s_addr=htonl(INADDR_ANY);	
	adr.sin_port=htons(atoi(argv[2]));
	
	if(bind(recv_sock, (struct sockaddr*) &adr, sizeof(adr))==-1)
		error_handling("bind() error");
	
	join_adr.imr_multiaddr.s_addr=inet_addr(argv[1]); // 初始化多播组地址。
	join_adr.imr_interface.s_addr=htonl(INADDR_ANY);  // 初始化待加入主机的IP地址。
  	
    // 利用套接字选项IP_ADD_MEMBERSHIP加入多播组。
	setsockopt(recv_sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, (void*)&join_adr, sizeof(join_adr));
  
	while(1)
	{
        // 通过调用recvfrom函数接收多播数据。如果不需要知道传输数据的主机地址信息,可以向recvfrom函数的第五个和第六个参数分别传递NULL和0。
		str_len=recvfrom(recv_sock, buf, BUF_SIZE-1, 0, NULL, 0);
		if(str_len<0) 
			break;
		buf[str_len]=0;
		fputs(buf, stdout);
	}
	close(recv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

注意:运行时两个文件都需要传入端口号和 IP 地址,并且一样。

广播

广播地址用于在同一个链路中相互连接的主机之间发送数据包。

多播即使在跨越不同网络的情况下,只要加入多播组就能接收数据。相反,广播只能向同一网络中的主机传输数据。

广播是向同一网络中的所有主机传输数据的方法。与多播相同,广播也是基于 UDP 完成的。根据传输数据时使用的 IP 地址的形式,广播分为如下 2 种:

  1. 直接广播(Directed Broadcast)

    直接广播的 IP 地址中除了网络地址外,其余主机地址全部设置为 1。例如,希望向网络地址 192.12.34 中的所有主机传输数据时,可以向 192.12.34.255 传输。换言之,可以采用直接广播的方式向特定区域内所有主机传输数据。

  2. 本地广播(Local Broadcast)

    本地广播中使用的 IP 地址限定为 255.255.255.255。例如,192.32.24 网络中的主机向 255.255.255.255 传输数据时,数据将传递到 192.32.24 网络中的所有主机。

数据通信中使用的 IP 地址是与 UDP 示例的唯一区别。默认生成的套接字会阻止广播,因此,只需通过如下代码更改默认设置。

int send_sock;
int bcast = 1;    // 对变量进行初始化以将 S0_BROADCAST 选项信息改为 1。
...
send_sock = socket(PF_INET, SOCK_DGRAM,O);
...
setsockopt(send_sock,SOL_SOCKET,SO_BROADCAST,(void*)& bcast,sizeof(bcast));
...

上述套接字选项只需在 Sender 中更改,Receiver 的实现不需要该过程。

实现广播 Sender 和 Receiver

// news_sender_brd.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int send_sock;
	struct sockaddr_in broad_adr;
	FILE *fp;
	char buf[BUF_SIZE];
	int so_brd=1;
	
	if(argc!=3) {
		printf("Usage : %s <Boradcast IP> <PORT>\n", argv[0]);
		exit(1);
	}
  
	send_sock=socket(PF_INET, SOCK_DGRAM, 0);	
	memset(&broad_adr, 0, sizeof(broad_adr));
	broad_adr.sin_family=AF_INET;
	broad_adr.sin_addr.s_addr=inet_addr(argv[1]);
	broad_adr.sin_port=htons(atoi(argv[2]));
	
	setsockopt(send_sock, SOL_SOCKET, SO_BROADCAST, (void*)&so_brd, sizeof(so_brd));	
	if((fp=fopen("news.txt", "r"))==NULL)
		error_handling("fopen() error");

	while(!feof(fp))
	{
		fgets(buf, BUF_SIZE, fp);
		sendto(send_sock, buf, strlen(buf), 0, (struct sockaddr*)&broad_adr, sizeof(broad_adr));
		sleep(2);
	}

	close(send_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}
// news_receiver_brdc

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int recv_sock;
	struct sockaddr_in adr;
	int str_len;
	char buf[BUF_SIZE];
	
	if(argc!=2) {
		printf("Usage : %s  <PORT>\n", argv[0]);
		exit(1);
	 }
  
	recv_sock=socket(PF_INET, SOCK_DGRAM, 0);
	
	memset(&adr, 0, sizeof(adr));
	adr.sin_family=AF_INET;
	adr.sin_addr.s_addr=htonl(INADDR_ANY);	
	adr.sin_port=htons(atoi(argv[1]));
	
	if(bind(recv_sock, (struct sockaddr*)&adr, sizeof(adr))==-1)
		error_handling("bind() error");
  
	while(1)
	{
		str_len=recvfrom(recv_sock, buf, BUF_SIZE-1, 0, NULL, 0);
		if(str_len<0) 
			break;
		buf[str_len]=0;
		fputs(buf, stdout);
	}
	
	close(recv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

多播和广播的区别

 

第十五章:套接字和标准I/O

标准I/O

优点

下面列出的是标准 I/O 函数的两大优点:

  1. 标准 I/O 函数具有良好的移植性。

    • 不仅是 I/O 函数,所有标准函数具有良好的移植性。

  2. 标准 I/O 函数可以利用缓冲提高性能。

    • 创建套接字时,操作系统将生成用于 I/O 的缓冲。此缓冲在执行 TCP 协议时发挥着非常重要的作用。此时若使用标准 I/O 函数,将得到额外的另一缓冲的支持。

      使用标准 I/O 函数传输数据时,经过 2 个缓冲。例如,通过 fputs 函数传输字符串"Hello"时,首先将数据传递到标准 I/O 函数的缓冲。然后数据将移动到套接字输出缓冲,最后将字符串发送到对方主机。 套接字中的缓冲主要是为了实现 TCP 协议而设立的。例如,TCP 传输中丢失数据时将再次传递,而再次发送数据则意味着在某地保存了数据。存在什么地方呢?套接字的输出缓冲。与之相反,使用标准 I/O 函数缓冲的主要目的是为了提高性能。

缺点

  1. 不容易进行双向通信。

  2. 有时可能频繁调用 fflush 函数。

    打开文件时,如果希望同时进行读写操作,则应以 r+、w+、a+ 模式打开。但因为缓冲的缘故,每次切换读写工作状态时应调用 fflush 函数。这也会影响基于缓冲的性能提高。

  3. 需要以 FILE 结构体指针的形式返回文件描述符。

    而创建套接字时默认返回文件描述符,因此需要将文件描述符转化为 FILE 指针。

使用标准 I/O 函数

利用 fdopen 函数转换为 FILE 结构体指针

#include <stdio.h>
FILE* fdopen(int fildes, const char* mode);

// 成功时返回转换的 FILE 结构体指针,失败时返回 NULL。

// fildes——需要转换的文件描述符。
// mode——将要创建的FILE结构体指针的模式(mode)信息。与fopen函数中的打开模式相同。常用的参数有读模式"r"和写模式"w"。

#include <stdio.h>
#include <fcntl.h>

int main(void)
{
	FILE *fp;
	int fd=open("data.dat", O_WRONLY|O_CREAT|O_TRUNC);
	if(fd==-1)
	{
		fputs("file open error", stdout);
		return -1;
	}

	fp=fdopen(fd, "w");   // int 转 FILE*
	fputs("Network C programming \n", fp);
	fclose(fp);
	return 0;
}


/*
root@my_linux:/tcpip# gcc desto.c-o desto 
root@my_linux:/tcpip# ./desto 
root@my_linux:/tcpip# cat data.dat 
Network C programming
*/

利用 fileno 函数转换为文件描述符

#include <stdio.h>
int fileno(FILE* stream);

// 成功时返回转换后的文件描述符,失败时返回 -1。

基于套接字的标准 I/O 函数使用

回声服务器端:基于第 4 章的 echo_server.c

回声客户端:基于第 4 章的 echo_client.c

// echo_stdserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock, clnt_sock;
	char message[BUF_SIZE];
	int str_len, i;
	
	struct sockaddr_in serv_adr;
	struct sockaddr_in clnt_adr;
	socklen_t clnt_adr_sz;
	FILE * readfp;
	FILE * writefp;
	
	if(argc!=2) {
		printf("Usage : %s <port>\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");
	
	clnt_adr_sz=sizeof(clnt_adr);

	for(i=0; i<5; i++)
	{
		clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
		if(clnt_sock==-1)
			error_handling("accept() error");
		else
			printf("Connected client %d \n", i+1);
	
		readfp=fdopen(clnt_sock, "r");
		writefp=fdopen(clnt_sock, "w");
	
		while(!feof(readfp))
		{
			fgets(message, BUF_SIZE, readfp);
			fputs(message, writefp);
             // 标准I/O函数为了提高性能,内部提供额外的缓冲。因此,若不调用fflush函数则无法保证立即将数据传输到客户端。
			fflush(writefp);  // 清空缓冲器。
		}
		fclose(readfp);
		fclose(writefp);
	}
	close(serv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}
// echo_client.d

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	struct sockaddr_in serv_adr;
	FILE * readfp;
	FILE * writefp;

	if(argc!=3) {
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_STREAM, 0);   
	if(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=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!");
	else
		puts("Connected...........");

	readfp=fdopen(sock, "r");
	writefp=fdopen(sock, "w");	

	while(1) 
	{
		fputs("Input message(Q to quit): ", stdout);
		fgets(message, BUF_SIZE, stdin);
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
			break;

		fputs(message, writefp);
		fflush(writefp);
 		fgets(message, BUF_SIZE, readfp);
		printf("Message from server: %s", message);
	}	
	fclose(writefp);
	fclose(readfp);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

第十六章:关于I/O流分离的其他内容

2 次 I/O 流分离

  1. 第一种是第 10 章的"TCPI/O 过程(Routine)分离"。这种方法通过调用 fork 函数复制出 1 个文件描述符,以区分输入和输出中使用的文件描述符。虽然文件描述符本身不会根据输入和输出进行区分,但我们分开了 2 个文件描述符的用途。

    • 通过分开输入过程(代码)和输出过程降低实现难度。

    • 与输入无关的输出操作可以提高速度。

  2. 第二种分离在第 15 章。通过 2 次 fdopen 函数的调用,创建读模式 FILE 指针(FILE 结构体指针)和写模式 FILE 指针。换言之,我们分离了输入工具和输出工具。

    • 为了将 FILE 指针按读模式和写模式加以区分。

    • 可以通过区分读写模式降低实现难度。

    • 通过区分 I/O 缓冲提高缓冲性能。

文件描述符的半关闭

下面是全关闭。

// sep_serv.C

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    FILE * readfp;
    FILE * writefp;

    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;
    char buf[BUF_SIZE]={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]));

    bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr));
    listen(serv_sock, 5);
    clnt_adr_sz=sizeof(clnt_adr); 
    clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr,&clnt_adr_sz);

    // 通过clnt_sock中保存的文件描述符创建读模式FILE指针和写模式FILE指针。
    readfp=fdopen(clnt_sock, "r");
    writefp=fdopen(clnt_sock, "w");

    fputs("FROM SERVER: Hi~ client? \n", writefp);
    fputs("I love all of the world \n", writefp);
    fputs("You are awesome! \n", writefp);
    fflush(writefp);  // 客户端发送字符串,调用fflush函数结束发送过程。

    // 针对写模式FILE指针调用fclose函数。调用fclose函数终止套接字时,对方主机将收到EOF。但还剩下读模式FILE指针。有些人可能认为可以通过fgets的函数调用接收客户端最后发送的字符串。当然,最后的字符串是客户端收到EOF后发送的。
    fclose(writefp);	             // 这里是全关闭。
    fgets(buf, sizeof(buf), readfp); // 由于上面全关闭,这一行没用了。
    fputs(buf, stdout); 
    fclose(readfp);
    return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
    int sock;
    char buf[BUF_SIZE];
    struct sockaddr_in serv_addr;

    FILE * readfp;
    FILE * writefp;

    sock=socket(PF_INET, SOCK_STREAM, 0);
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));

    connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    readfp=fdopen(sock, "r");
    writefp=fdopen(sock, "w");

    while(1)
    {
        if(fgets(buf, sizeof(buf), readfp)==NULL) 
            break;
        fputs(buf, stdout);
        fflush(stdout);
    }  

    // 通过该行语句向服务器端发送最后的字符串。当然,该字符串是在收到服务器端的EOF后发送的。
    fputs("FROM CLIENT: Thank you! \n", writefp);
    fflush(writefp);
    fclose(writefp); fclose(readfp);
    return 0;
}

终止"流"时无法半关闭的原因:

sep_serv.c 中的读模式 FILE 指针和写模式 FILE 指针都是基于同一文件描述符创建的。因此,针对任意一个 FILE 指针调用 fclose 函数时都会关闭文件描述符,也就终止套接字。

解决办法:复制后另外创建 1 个文件描述符。

原因:销毁所有文件描述符后才能销毁套接字。

新的问题:

仔细观察,还剩 1 个文件描述符呢。该文件描述符可以同时进行 I/O。因此,不但没有发送EOF,而且仍然可以利用文件描述符进行输出。因此没有真正半关闭。

文件描述符的复制:dup、dup2

#include <unistd.h>
int dup(int fildes);
int dup2(int fildes, int fildes2);

// 成功时返回复制的文件描述符,失败时返回 -1。

// fildes——需要复制的文件描述符。
// fildes2——明确指定的文件描述符整数值。

dup2 函数明确指定复制的文件描述符整数值。向其传递大于 0 且小于进程能生成的最大文件描述符值时,该值将成为复制出的文件描述符值。下面给出示例验证函数功能,示例中将复制自动打开的标准输出的文件描述符 1,并利用复制出的描述符进行输出。另外,自动打开的文件描述符 0、1、2 与套接字文件描述符没有区别,因此可以用来验证 dup 函数的功能。

// dup.c
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	int cfd1, cfd2;
	char str1[]="Hi~ \n";
	char str2[]="It's nice day~ \n";

	cfd1=dup(1);  // 1 是标准输出。
	cfd2=dup2(cfd1, 7);
	
	printf("fd1=%d, fd2=%d \n", cfd1, cfd2);
    // 利用复制出的文件描述符进行输出。通过该输出结果可以验证是否进行了实际复制。
	write(cfd1, str1, sizeof(str1));
	write(cfd2, str2, sizeof(str2));
	
    // 终止复制的文件描述符。但仍有1个描述符,因此可以进行输出。可以从下面第三行得到验证。
	close(cfd1);
	close(cfd2);
	write(1, str1, sizeof(str1));
    
    // 终止最后的文件描述符,因此无法完成下面第二行的输出。
	close(1);
	write(1, str2, sizeof(str2));
    
	return 0;
}

/*
fd1=3,fd2=7 
Hi~
It's nice day~
Hi~
*/

复制文件描述符后"流"的分离

通过服务器端的半关闭状态接收客户端最后发送的字符串。当然,为了完成这一任务,服务器端需要同时发送 EOF。

// sep_serv2.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    FILE * readfp;
    FILE * writefp;

    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;
    char buf[BUF_SIZE]={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]));

    bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr));
    listen(serv_sock, 5);
    clnt_adr_sz=sizeof(clnt_adr); 
    clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr,&clnt_adr_sz);

    readfp=fdopen(clnt_sock, "r");        // 调用fdopen函数生成FILE指针。
    writefp=fdopen(dup(clnt_sock), "w");  // dup函数的返回值生成FILE指针。

    fputs("FROM SERVER: Hi~ client? \n", writefp);
    fputs("I love all of the world \n", writefp);
    fputs("You are awesome! \n", writefp);
    fflush(writefp);

    // 针对fileno函数返回的文件描述符调用shutdown函数。因此,服务器端进入半关团状态,并向客户端发送EOF。这一行就是之前所说的发送EOF的方法。调用shutdown函数时,无论复制出多少文件描述符都进入半关闭状态,同时传递EOF。
    shutdown(fileno(writefp), SHUT_WR);
    fclose(writefp);

    fgets(buf, sizeof(buf), readfp); 
    fputs(buf, stdout); 
    fclose(readfp);
    return 0;
}

/*
swyoon@my_linux:~/tcpip$ gcc sep_serv2.c -o serv2
swyoon@my_linux:~/tcpip$ ./serv2 9190
FROM CLIENT: Thank you! 
*/

第十七章:优于select的epoll

基于 select 的 I/O 复用技术速度慢的原因

  • 调用 select 函数后常见的针对所有文件描述符的循环语句。

  • 每次调用 select 函数时都需要向该函数传递监视对象信息。

改进:仅向操作系统传递 1 次监视对象,监视范围或内容发生变化时只通知发生变化的事项。

epoll

epoll 的优点:

  1. 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。

  2. 调用对应于 select 函数的 epoll_wait 函数时无需每次传递监视对象信息。

三个函数:

  1. epoll_create:创建保存 epoll 文件描述符的空间。

  2. cpoll_ctl:向空间注册并注销文件描述符。

  3. epoll_wait:与 select 函数类似,等待文件描述符发生变化。

epoll 方式中通过结构体 epoll_event(类似于 fd_set) 将发生变化的(发生事件的)文件描述符单独集中到一起。

struct epoll_event {
    uint32_t events;
    epoll_data_t data;
}
// 注意这是一个联合体
typedef union epoll_data{
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
}epoll_data_t;

epoll_event 里的 events 的取值:

EPOLLIN:需要读取数据的情况。
EPOLLOUT:输出缓冲为空,可以立即发送数据的情况。
EPOLLPRI:收到OOB数据的情况。
EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用。
EPOLLERR:发生错误的情况。
EPOLLET:以边缘触发的方式得到事件通知。
EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD,再次设置事件。

epoll_create

#include <sys/epoll.h>
int epoll_create(int size);

// 成功时返回 epoll 文件描述符,失败时返回 -1。

// size——epoll实例的大小。

size 并非用来决定 epoll 例程的大小,而仅供操作系统参考。

该函数返回的文件描述符将用作其他所有 epoll 系统调用的第一个参数,以指定要访问的内核事件表。

cpoll_ctl

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

// 成功时返回 0,失败时返回 -1。

// epfd——用于注册监视对象的epoll例程的文件描述符。
// op——用于指定监视对象的添加、删除或更改等操作。
// fd——需要注册的监视对象文件描述符。
// event——监视对象的事件类型。

第二个参数 op:

EPOLL_CTL_ADD∶将文件描述符注册到 epoll 例程。

EPOLL_CTL_DEL∶从 epoll 例程中删除文件描述符。

EPOLL_CTL_MOD∶更改注册的文件描述符的关注事件发生情况。

举例:

epoll_ctl(A,EPOLL_CTL_ADD,B,C);
// epoll例程A中注册文件描述符B,主要目的是监视参数C中的事件。


epoll_ctl(A,EPOLL_CTL_DEL, B, NULL);
// 从epoll例程A中删除文件描述符B。

例程:

struct epoll_event event;
...
event.events=EPOLLIN; //发生需要读取数据的情况(事件)时
event.data.fd=sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
...

将 sockfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。

epoll_wait

#include <Sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

// 成功时返回发生事件的文件描述符数,失败时返回 -1。

// epfd——表示事件发生监视范围的epoll例程的文件描述符。
// events——保存发生事件的文件描述符集合的结构体地址值。
// maxevents——第二个参数中可以保存的最大事件数。
// timeout——以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件。

第二个参数所指缓冲需要动态分配

int event_cnt;
struct epoll_event* ep_events;
...
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE); // EPOLL_SIZE是宏常量
...
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
...

epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。

基于 epoll 的回声服务器端

类似于 select 的用法。

// echo_epollserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char* buf);

int main(int argc, char* argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;

    if(argc!=2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    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");

    epfd=epoll_create(EPOLL_SIZE);
    ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);

    event.events=EPOLLIN;
    event.data.fd=serv_sock;	
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);  // 把服务器的文件描述符加入监听。

    printf("serv_sock=%d\n",serv_sock);
    printf("epfd=%d\n",epfd); 

    while(1)
    {
        event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);  // 成功时返回发生事件的文件描述符数量。
        printf("event_cnt=%d\n",event_cnt);

        if(event_cnt==-1)  // 上面那行失败时返回 -1.
        {
            puts("epoll_wait() error");
            break;
        }

        for(i=0; i<event_cnt; i++)  // ep_events[i].data.fd肯定就绪了,就需要判断一下是不是服务器端还是其他。event_cnt是发生事件的数量。ep_events只保存发生的事件。
        {
            if(ep_events[i].data.fd==serv_sock)   // 有客户端连接上了。
            {
                printf("for_if:ep_events[i].data.fd=%d,i=%d\n",ep_events[i].data.fd,i);
                adr_sz=sizeof(clnt_adr);
                clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                event.events=EPOLLIN;
                event.data.fd=clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);  // 把客户端的文件描述符加入监听。
                printf("connected client: %d \n", clnt_sock);
            }
            else   // 客户端传递了消息。
            {
                printf("for_else:ep_events[i].data.fd=%d,i=%d\n",ep_events[i].data.fd,i);
                str_len=read(ep_events[i].data.fd, buf, BUF_SIZE);
                if(str_len==0)    // close request!
                {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); // 删除监听。
                    close(ep_events[i].data.fd);    // 关闭客户端。
                    printf("closed client: %d \n", ep_events[i].data.fd);
                }
                else
                {
                    write(ep_events[i].data.fd, buf, str_len);    // echo!
                }

            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}

void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

/*
z@ubuntu:~/Desktop/Chapter17$ gcc echo_epollserv.c -o s
z@ubuntu:~/Desktop/Chapter17$ ./s 9190
serv_sock=3
epfd=4
event_cnt=1
for_if:ep_events[i].data.fd=3,i=0
connected client: 5 
event_cnt=1
for_else:ep_events[i].data.fd=5,i=0
*/
// echo_client.c 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len;
    struct sockaddr_in serv_adr;

    if(argc!=3) {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock=socket(PF_INET, SOCK_STREAM, 0);   
    if(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=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!");
    else
        puts("Connected...........");

    while(1) 
    {
        fputs("Input message(Q to quit): ", stdout);
        fgets(message, BUF_SIZE, stdin);

        if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
            break;

        write(sock, message, strlen(message));
        str_len=read(sock, message, BUF_SIZE-1);
        message[str_len]=0;
        printf("Message from server: %s", message);
    }

    close(sock);
    return 0;
}



void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

/*
z@ubuntu:~/Desktop/Chapter17$ ./c 127.0.0.1 9190
Connected...........
Input message(Q to quit): one
Message from server: one
Input message(Q to quit): 
*/

顺序理解:

  1. 先把服务器端的套接字加入监听。

  2. 运行服务器端代码。

  3. 一旦客户端的代码开始运行,服务器端就和客户端连接上了。此时监听到了服务器端的事件。将客户端的套接字加入监听。

  4. 客户端发送信息,就监听到了客户端的事件。

条件触发(LT)和边缘触发(ET)

条件触发方式中,只要输入缓冲有数据就会一直通知该事件。

例如,服务器端输入缓冲收到 50 字节的数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务器端读取 20 字节后还剩 30 字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。

边缘触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再进行注册。

对于采用 LT 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用 epoll_wait 时,cpoll_wait 还会再次向应用程序通告此事件,直到该事件被处理。而对于采用 ET 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll_wait 调用将不再向应用程序通知这一事件。可见,ET模式在很大程度上降低了同一个 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。

条件触发

epoll 默认以条件触发方式工作。

// echo_EPLTserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE 4
#define EPOLL_SIZE 50
void error_handling(char *buf);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;

    if(argc!=2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    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");

    epfd=epoll_create(EPOLL_SIZE);
    ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);

    event.events=EPOLLIN;
    event.data.fd=serv_sock;	
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    while(1)
    {
        event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if(event_cnt==-1)
        {
            puts("epoll_wait() error");
            break;
        }

        puts("return epoll_wait");
        for(i=0; i<event_cnt; i++)
        {
            if(ep_events[i].data.fd==serv_sock)
            {
                adr_sz=sizeof(clnt_adr);
                clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                event.events=EPOLLIN;
                event.data.fd=clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connected client: %d \n", clnt_sock);
            }
            else
            {
                str_len=read(ep_events[i].data.fd, buf, BUF_SIZE);
                if(str_len==0)    // close request!
                {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("closed client: %d \n", ep_events[i].data.fd);
                }
                else
                {
                    write(ep_events[i].data.fd, buf, str_len);    // echo!
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}

void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}


/*
运行结果∶echo_EPLTserv.c
root@my linux:/tcpip# gcc echo EPLTserv.c -o serv 
root@my_linux:/tcpip#./serv 9190
return epoll_wait 
connected client:5    // 上面那行是它的
return epoll_wait     // It's 
return epoll_wait     // my 
return epoll wait     // life
return epoll_wait     // \0
return epoll_wait 
connected client:6    // 上面那行是它的 
return epoll_wait     // It's 
return epoll_wait     // you
return epoll wait     // r li
return epoll_wait     // fe
return epoll_wait 
closed client:5       // 上面那行是它的
return epoll_wait 
closed client:6       // 上面那行是它的

运行结果∶ echo_client.c One
root@my_linux:/tcpip# gcc echo_client.c -o client 
root@my_linux:/tcpip# ./client 127.0.0.1 9190 
Connected..........
Input message(Q to quit):It's my life 
Message from server: It's my life 
Input message(Q to quit): Q

运行结果∶ echo_client.c Two
root@my_linux:/tcpip# gcc sep_clnt.c -o clnt 
root@my_linux:/tcpip#./client 127.0.0.1 9190 
Connected...........
Input message(Q to quit):It's your life 
Message from server: It's your life 
Input message(Q to quit): Q
*/

上述示例与之前的 echo_epollserv.c 之间的差异如下:

  1. 将调用 read 函数时使用的缓冲大小缩减为 4 个字节。

  2. 插入验证 epoll_wait 函数调用次数的语句(puts("return epoll_wait")。

减少缓冲大小是为了阻止服务器端一次性读取接收的数据。换言之,调用 read 函数后,输入缓冲中仍有数据需要读取。而且会因此注册新的事件并从 epoll_wait 函数返回时将循环输出"return epoll wait"字符串。该程序同样可以结合第 4 章的 echo_client.c 运行。

边缘触发(fcntl 更改属性)

  • 通过 errno 变量验证错误原因

    Linux 的套接字相关函数一般通过返回 -1 通知发生了错误。虽然知道发生了错误,但仅凭这些内容无法得知产生错误的原因。因此,为了在发生错误时提供额外的信息,Linux 声明了如下全局变量:

    #include <errorh.h>
    int errno;

    为了访问该变量,需要引入 errorh 头文件,因为此头文件中有上述变量的 extern 声明。

  • 为了完成非阻塞(Non-blocking)I/O,更改套接字特性

    更改或读取文件属性:

    #include <fcntl.h>
    int fcntl(int filedes, int cmd,...);
    
    // 成功时返回 cmd 参数相关值,失败时返回 -1。
    
    // filedes——属性更改目标的文件描述符。
    // cmd——表示函数调用的目的。

    如果向第二个参数传递 F_GETFL,可以获得第一个参数所指的文件描述符属性(int 型)。反之,如果传递 F_SETFL,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下2条语句:

    int flag = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flag|O_NONBLOCK);

    通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read&write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。

首先说明为何需要通过 errno 确认错误原因。 "边缘触发方式中,接收数据时仅注册 1 次该事件。" 就因为这种特点,一旦发生输入相关事件,就应该读取输入缓冲中的全部数据。因此需要验证输入缓冲是否为空。 "read 函数返回 -1,变量 errno 中的值为 EAGAIN 时,说明没有数据可读。"

既然如此,为何还需要将套接字变成非阻塞模式?边缘触发方式下,以阻塞方式工作的 read & write 函数有可能引起服务器端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞 read & write 函数。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE 4   // 为了验证边缘触发的工作方式,将缓冲设置为4字节。
#define EPOLL_SIZE 50
void setnonblockingmode(int fd);
void error_handling(char *buf);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;

    if(argc!=2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    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");

    epfd=epoll_create(EPOLL_SIZE);
    ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);

    setnonblockingmode(serv_sock);
    event.events=EPOLLIN;
    event.data.fd=serv_sock;	
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    while(1)
    {
        event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if(event_cnt==-1)
        {
            puts("epoll_wait() error");
            break;
        }

        puts("return epoll_wait");  // 为观察事件发生数而添加的输出字符串的语句。
        for(i=0; i<event_cnt; i++)
        {
            if(ep_events[i].data.fd==serv_sock)
            {
                adr_sz=sizeof(clnt_adr);
                clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                setnonblockingmode(clnt_sock);  // 将accept函数创建的套接字改为非阻塞模式。
                event.events=EPOLLIN|EPOLLET;   // 向EPOLLIN添加EPOLLET标志,将套接字事件注册方式改为边缘触发。
                event.data.fd=clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connected client: %d \n", clnt_sock);
            }
            else
            {
                while(1) // 之前的条件触发回声服务器端中没有该while循环。
                {
                    // 边缘触发方式中,发生事件时需要读取输入缓冲中的所有数据,因此需要循环调用read函数。
                    str_len=read(ep_events[i].data.fd, buf, BUF_SIZE);
                    if(str_len==0)    // close request!
                    {
                        epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                        close(ep_events[i].data.fd);
                        printf("closed client: %d \n", ep_events[i].data.fd);
                        break;
                    }
                    // read函数返回-1且errno值为上AGAIN时,意味读取了输入缓冲中的全部数据,因此需要通过break跳出第循环。
                    else if(str_len<0)
                    {
                        if(errno==EAGAIN)
                            break;
                    }
                    else
                    {
                        write(ep_events[i].data.fd, buf, str_len);    // echo!
                    }
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}

void setnonblockingmode(int fd)           // 非阻塞
{
    int flag=fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flag|O_NONBLOCK);
} 

void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

/*
运行结果∶echo_EPETserv.c
root@my_linux:/tcpip# gcc echo_EPETserv.c -o serv 
root@my_linux:/tcpip#./serv 9190 
return epoll_wait 
connected client: 5 
return epoll_wait return epoll_wait return epoll_wait return epoll_wait closed client:5
运行结果∶ echo_client.
root@my_linux:/tcpip# gcc echo_client.c -0 clnt root@my_linux:/tcpip# ./clnt 127.0.0.1 9190 Connected...........
Input message(Q to quit):I like computer programming Message from server:I like computer programming
Input message(Q to quit):Do you like computer programming?Message from server:Do you like computer programming?Input message(Q to quit):Good bye Message from server: Good bye

select、poll、epoll 的比较

这 3 组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。select 的参数类型 fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select 需要提供 3 个这种类型的参数来分别传入和输出可读、可写及异常等事件。这一方面使得 select 不能处理更多类型的事件,另一方面由于内核对 fd_set 集合的在线修改,应用程序下次调用 select 前不得不重置这 3 个 fd_set 集合。

poll 的参数类型 pollfd 则多少"聪明"一些。它把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁得多。并且内核每次修改的是 pollfd 结构体的 revents 成员,而 events 成员保持不变,因此下次调用 poll 时应用程序无须重置 pollfd 类型的事件集参数。由于每次 select 和 poll 调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为 O(n)。

epoll 则采用与 select 和 poll 完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用 epoll_ctl 来控制往其中添加、删除、修改事件。这样,每次 epoll_wait 调用都直接从该内核事件表中取得用户注册的事件,而无须反复从用户空间读人这些事件。epoll_wait 系统调用的 events 参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到 O(1)。

poll 和 epoll_wait 分别用 nfds 和 maxevents 参数指定最多监听多少个文件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述符数目,即 65535(cat/proc/sys/fs/file-max)。而 select 允许监听的最大文件描述符数量通常有限制。虽然用户可以修改这个限制,但这可能导致不可预期的后果。

select 和 poll 都只能工作在相对低效的 LT 模式,而 epoll 则可以工作在 ET 高效模式。并且 epoll 还支持 EPOLLONESHOT(当一个线程(或进程)在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕,该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个s ocket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。) 事件。该事件能进一步减少可读、可写和异常等事件被触发的次数。 从实现原理上来说,select 和 poll 采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是 O(n)。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此 epoll_wait 无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是 O(1)。但是,当活动连接比较多的时候,epoll_wait 的效率未必比 select 和 poll 高,因为此时回调函数被触发得过于频繁。所以 epoll_wait 适用于连接数量多,但活动连接较少的情况。

第十八章:多线程服务器端的实现

操作系统、进程、线程

进程缺点:

  1. 创建进程的过程会带来一定的开销。

  2. 为了完成进程间数据交换,需要特殊的 IPC 技术。

每个进程的内存空间都由保存全局变量的"数据区"、向 malloc 等函数的动态分配提供空间的堆(Heap)、函数运行时使用的栈(Stack)构成。每个进程都拥有这种独立空间,多个进程的内存结构如图所示。

线程的优点:

  1. 上下文切换时不需要切换数据区和堆

  2. 可以利用数据区和堆交换数据。

线程的内存结构:

操作系统、进程、线程之间的关系:

线程创建及运行

pthread_create

#include <pthread.h>
int pthread_create(pthread_t * restrict thread,
                   const pthread_attr_t * restrict attr, 
                   void*(* start_routine)(void *),
                   void * restrict arg 
                  );

// 成功时返回 0,失败时返回其他值。

// thread——保存新创建线程ID的变量地址值。线程与进程相同,也需要用于区分不同线程的ID。
// attr——用于传递线程属性的参数,传递NULL时,创建默认属性的线程。
// start_routine——相当于线程main函数的、在单独执行流中执行的函数地址值(函数指针)。
// arg——通过第三个参数传递调用函数时包含传递参数信息的变量地址值。
 

pthread_join

通过调用 sleep 函数控制线程的执行相当于预测程序的执行流程,但实际上这是不可能完成的事情。因此,我们不用 sleep 函数,而是通常利用下面的函数控制线程的执行流。通过下列函数可以更有效地解决现讨论的问题,还可同时了解线程 ID 的用法。

# include <pthread.h>
int pthread_join(pthread_t thread,void* status);

// 成功时返回 0,失败时返回其他值。

// thread——该参数值ID的线程终止后才会从该函数返回。
// status——保存线程的main函数返回值的指针变量地址值。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
void* thread_main(void *arg);

int main(int argc, char *argv[]) 
{
    pthread_t t_id;
    int thread_param=5;
    void * thr_ret;

    if(pthread_create(&t_id, NULL, thread_main, (void*)&thread_param)!=0) // 成功返回0
    {
        puts("pthread_create() error");
        return -1;
    }; 	

    // main函数中,针对上面创建的线程调用pthread_join函数。因此,main函数将等待ID保存在t_id变量中的线程终止。
    if(pthread_join(t_id, &thr_ret)!=0)  // 成功返回0
    {
        puts("pthread_join() error");
        return -1;
    };

    printf("Thread return message: %s \n", (char*)thr_ret);
    free(thr_ret);
    return 0;
}

void* thread_main(void *arg) 
{
    int i;
    int cnt=*((int*)arg);
    char * msg=(char *)malloc(sizeof(char)*50);
    strcpy(msg, "Hello, I'am thread~ \n");

    for(i=0; i<cnt; i++)
    {
        sleep(1);  
        puts("running thread");	 
    }
    
    // 返回的值将保存到pthread_join函数的第二个参数thr_ret。需要注意的是,该返回值是thread_main函数内部动态分配的内存空间地址值。
    return (void*)msg;
}

/*
root@my_linux:/tcpip# gcc thread2.c -o tr2 -lpthread 
root@my_linux:/tcpip# ./tr2 
running thread 
running thread 
running thread 
running thread 
running thread
Thread return message: Hello,I'am thread~
*/

可在临界区内调用的函数

关于线程的运行需要考虑"多个线程同时调用函数时(执行时)可能产生问题"。这类函数内部存在临界区。

根据临界区是否引起问题,函数可分为以下2类:

  1. 线程安全函数(Thread-safe function)

  2. 非线程安全函数(Thread-unsafe function)

线程安全的函数中同样可能存在临界区。只是在线程安全函数中,同时被多个线程调用时可通过一些措施避免问题。

幸运的是,大多数标准函数都是线程安全的函数。更幸运的是,我们不用自己区分线程安全的函数和非线程安全的函数。因为这些平台在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数。比如,第8章介绍过的如下函数就不是线程安全的函数: gethostbyname,同时提供线程安全的同一功能的函数: gethostbyname_r

线程安全函数的名称后缀通常为 r。可以通过如下方法自动将 gethostbyname 函数调用改为 gethostbyname_r 函数调用: "声明头文件前定义 REENTRANT 宏。"

gethostbyname 函数和 gethostbyname_r 函数的函数名和参数声明都不同,因此,这种宏声明方式拥有巨大的吸引力。另外,无需为了上述宏定义特意添加 #define 语句,可以在编译时通过添加 -D_REENTRANT 选项定义宏。

root@my_linux:/tcpip# gcc -D_REENTRANT mythread.c -o mthread -lpthread 

线程存在的问题和临界区

任何内存空间——只要被同时访问——都可能发生问题。

"不是说线程会分时使用CPU吗?那应该不会出现同时访问变量的情况啊。"

注意上面的“同时访问”,并不是真的同一个时间一起操作。

线程同步

需要考虑的情况:

  1. 同时访问同一内存空间时发生的情况。

    • 上面讨论的情况就是这种。

  2. 需要指定访问同一内存空间的线程执行顺序的情况。

    • 假设有 A、B 两个线程,线程 A 负责向指定内存空间写入(保存)数据,线程 B 负责取走该数据。这种情况下,线程 A 首先应该访问约定的内存空间并保存数据。

互斥量

互斥量的创建及销毁函数:

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);
int pthread_mutex_destroy(pthread_mutex_t * mutex);

// 成功时返回 0,失败时返回其他值。

// mutex——创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址值。
// attr——传递即将创建的互斥量属性,没有特别需要指定的属性时传递NULL。

互斥量锁住或释放临界区时使用的函数:

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t * mutex);
int pthread_mutex_unlock(pthread_mutex_t * mutex);

// 成功时返回 0,失败时返回其他值。

进入临界区前调用的函数就是 pthread_mutex_lock。调用该函数时当前线程将一直处于阻塞状态。

创建好互斥量的前提下,可以通过如下结构保护临界区。

pthread_mutex_lock(&mutex);
// 临界区的开始
..........
// 临界区的结束
pthread_mutex unlock(&mutex);

接下来利用互斥量解决示例 thread4.c 中遇到的问题。

// mutex.c

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#define NUM_THREAD	100

void * thread_inc(void * arg);
void * thread_des(void * arg);

long long num=0;

// 声明了保存互斥量读取值的变量。之所以声明全局变量是因为,thread_inc函数和thread des函数都需要访问互斥量。
pthread_mutex_t mutex;

int main(int argc, char *argv[]) 
{
    pthread_t thread_id[NUM_THREAD];
    int i;

    pthread_mutex_init(&mutex, NULL);

    for(i=0; i<NUM_THREAD; i++)
    {
        if(i%2)
            pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);
        else
            pthread_create(&(thread_id[i]), NULL, thread_des, NULL);	
    }	

    for(i=0; i<NUM_THREAD; i++)
        pthread_join(thread_id[i], NULL);

    printf("result: %lld \n", num);
    pthread_mutex_destroy(&mutex);  // 销毁互斥量。不需要互斥量时应该销毁。
    return 0;
}

void * thread_inc(void * arg) 
{
    int i;
    pthread_mutex_lock(&mutex);
    // 实际临界区只是num+=1;这一行。但此处连同循环语句一起用作临界区,调用了lock、unlock函数。关于这一点稍后再讨论。
    for(i=0; i<50000000; i++)
        num+=1;
    pthread_mutex_unlock(&mutex);
    
    return NULL;
}
void * thread_des(void * arg)
{
    int i;
    for(i=0; i<50000000; i++)
    {
        pthread_mutex_lock(&mutex);
        num-=1;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

/*
swyoon@com:~/tcpip$ gcc mutex.c -D_REENTRANT -o mutex -lpthread
swyoon@com:~/tcpip$ ./mutex
result: 0 
*/

从运行结果可以看出,已解决了示例 thread4.c 中的问题。

但确认运行结果需要等待较长时间。因为互斥量 lock、unlock 函数的调用过程要比想象中花费更长时间。

首先分析一下 thread_inc 函数的同步过程。

void * thread_inc(void * arg) 
{
    int i;
    pthread_mutex_lock(&mutex);
    // 实际临界区只是num+=1;这一行。但此处连同循环语句一起用作临界区,调用了lock、unlock函数。关于这一点稍后再讨论。
    for(i=0; i<50000000; i++)
        num+=1;
    pthread_mutex_unlock(&mutex);
    
    return NULL;
}

以上临界区划分范围较大,但这是考虑到如下优点所做的决定:最大限度减少互斥量 lock、unlock 函数的调用次数。

上述示例中,thread_es 函数比 thread_inc 函数多调用 49999999 次互斥量 lock、unlock 函数,

信号量

信号量创建及销毁函数:

#include <semaphore.h>
int sem_init(sem_t * sem, int pshared, unsigned int value);
int sem_destroy(sem_t * sem);

// 成功时返回 0,失败时返回其他值。

// sem——创建信号量时传递保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值。
// pshared——传递其他值时,创建可由多个进程共享的信号量:传递0时,创建只允许1个进程内部使用的信号量。我们需要完成同一进程内的线程同步,故传递0。
// value——指定新创建的信号量初始值。

信号量中相当于互斥量 lock、unlock 的函数:

#include <semaphore.h>
int sem_post(sem_t * sem);
int sem_wait(sem_t * sem);

// 成功时返回 0,失败时返回其他值。

// sem——传递保存信号量读取值的变量地址值,传递给sem_post时信号量增1,传递给sem_wait时信号量减1。

调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录着"信号量值"整数。该值在调用 sem_post 函数时增 1,调用 sem_wait 函数时减 1。但信号量的值不能小于 0。因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用函数的线程将进入阻塞状态。

可以通过如下形式同步临界区(假设信号量的初始值为 1)。

sem_wait(&sem); //信号量变为0... 
// 临界区的开始
.........
// 临界区的结束
sem_post(&sem); //信号量变为1...

信号量的值在 0 和 1 之间跳转,具有这种特性的机制称为"二进制信号量"。

关于控制访问顺序的同步。该示例的场景如下∶ "线程 A 从用户输入得到值后存入全局变量 num,此时线程 B 将取走该值并累加。该过程共进行 5 次,完成后输出总和并退出程序。" 为了按照上述要求构建程序,应按照线程 A、线程 B 的顺序访问变量 num,且需要线程同步。

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

void * read(void * arg);
void * accu(void * arg);
static sem_t sem_one;
static sem_t sem_two;
static int num;

int main(int argc, char *argv[])
{
    pthread_t id_t1, id_t2;
    // 生成2个信号量,一个信号量的值为0,另一个为1。
    sem_init(&sem_one, 0, 0);
    sem_init(&sem_two, 0, 1);

    pthread_create(&id_t1, NULL, read, NULL);
    pthread_create(&id_t2, NULL, accu, NULL);

    pthread_join(id_t1, NULL);
    pthread_join(id_t2, NULL);

    sem_destroy(&sem_one);
    sem_destroy(&sem_two);
    return 0;
}

void * read(void * arg)
{
    int i;
    for(i=0; i<5; i++)
    {
        fputs("Input num: ", stdout);

        sem_wait(&sem_two);      // 标记1
        scanf("%d", &num);
        sem_post(&sem_one);      // 标记4
    }
    return NULL;	
}
void * accu(void * arg)
{
    int sum=0, i;
    for(i=0; i<5; i++)
    {
        sem_wait(&sem_one);      // 标记3
        sum+=num;
        sem_post(&sem_two);      // 标记2
    }
    printf("Result: %d \n", sum);
    return NULL;
}

/*
Input num: 1 
Input num: 2 
Input num: 3 
Input num: 4 
Input num: 5 
Result: 15
*/

标记 1 和标记 2 利用信号量变量 sem_two 调用 wait 函数和 post 函数。这是为了防止在调用 accu 函数的线程还未取走数据的情况下,调用 read 函数的线程覆盖原值。

标记 3 和标记 4 利用信号量变量 sem_one 调用 wait 和 post 函数。这是为了防止调用 read 函数的线程写入新值前,accu 函数取走(再取走旧值)数据。

线程的销毁和多线程并发服务器端的实现

销毁线程:

  1. 调用 pthread_join 函数。

  2. 调用 pthread_detach 函数。

之前调用过 pthread_ioin 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通常通过如下函数调用引导线程销毁。

#include <pthread.h>
int pthread_detach(pthread_t thread);

// 成功时返回 0,失败时返回其他值。

// thread——终止的同时需要销毁的线程ID。

调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。

多线程并发服务器端的实现

多个客户端之间可以交换信息的简单的聊天程序。

// chat_server.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <pthread.h>

#define BUF_SIZE 100
#define MAX_CLNT 256

void * handle_clnt(void * arg);
void send_msg(char * msg, int len);
void error_handling(char * msg);

// 用干管理接入的客户端套接字的变量和数组。访问这2个全局变量的代码将构成临界区。
int clnt_cnt=0;
int clnt_socks[MAX_CLNT];

pthread_mutex_t mutx;

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    int clnt_adr_sz;
    pthread_t t_id;
    if(argc!=2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    pthread_mutex_init(&mutx, NULL);             // 创建互斥量

    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)
    {
        clnt_adr_sz=sizeof(clnt_adr);

        // 下面的套接字文件描述符在handle_clnt函数里关闭。
        clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);

        pthread_mutex_lock(&mutx);
        clnt_socks[clnt_cnt++]=clnt_sock;  // 每当有新连接时,将相关信息写入变量clnt_cnt和clnt_socks。因为clnt_socks和clnt_cnt是全局变量,因此需要互斥量锁一下。
        pthread_mutex_unlock(&mutx);

        // 创建线程向新接入的客户端提供服务。由该线程执行handle_clnt函数。
        pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
        // 调用pthread_detach函数从内存中完全销毁已终止的线程。
        pthread_detach(t_id);
        printf("Connected client IP: %s \n", inet_ntoa(clnt_adr.sin_addr));
    }
    close(serv_sock);
    return 0;
}

void * handle_clnt(void* arg)
{
    int clnt_sock=*((int*)arg);
    int str_len=0, i;
    char msg[BUF_SIZE];

    while((str_len=read(clnt_sock, msg, sizeof(msg)))!=0)
        send_msg(msg, str_len);

    // 能运行到下面的代码,那么一定是从上面的while循环里出来了,说明read函数返回0,客户端代码那边关闭了。因此需要在服务器端的代码关闭客户端套接字。
    pthread_mutex_lock(&mutx);
    for(i=0; i<clnt_cnt; i++)   // remove disconnected client
    {
        if(clnt_sock==clnt_socks[i])
        {
            while(i++<clnt_cnt-1)   // 从要关闭的客户端套接字开始一个个往前移。
                clnt_socks[i]=clnt_socks[i+1];
            break;
        }
    }
    clnt_cnt--;
    pthread_mutex_unlock(&mutx);

    close(clnt_sock);
    return NULL;
}
void send_msg(char* msg, int len)   // 该函数负责向所有连接的客户端发送消息。
{
    int i;
    pthread_mutex_lock(&mutx);
    for(i=0; i<clnt_cnt; i++)        // 轮流向所有客户端发送接收来的消息。
        write(clnt_socks[i], msg, len);  // 全局变量,先锁一下。
    pthread_mutex_unlock(&mutx);
}
void error_handling(char * msg)
{
    fputs(msg, stderr);
    fputc('\n', stderr);
    exit(1);
}

可以结合运行结果看这个代码。

// chat_clnt.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> 
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>

#define BUF_SIZE 100
#define NAME_SIZE 20

void * send_msg(void * arg);
void * recv_msg(void * arg);
void error_handling(char * msg);

char name[NAME_SIZE]="[DEFAULT]";
char msg[BUF_SIZE];

int main(int argc, char *argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    pthread_t snd_thread, rcv_thread;
    void * thread_return;
    if(argc!=4) {
        printf("Usage : %s <IP> <port> <name>\n", argv[0]);
        exit(1);
    }

    sprintf(name, "[%s]", argv[3]);       // 给name重新赋值。
    sock=socket(PF_INET, SOCK_STREAM, 0);

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));

    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
        error_handling("connect() error");

    pthread_create(&snd_thread, NULL, send_msg, (void*)&sock);
    pthread_create(&rcv_thread, NULL, recv_msg, (void*)&sock);
    pthread_join(snd_thread, &thread_return);
    pthread_join(rcv_thread, &thread_return);
    close(sock);  
    return 0;
}

void* send_msg(void* arg)   // send thread main
{
    int sock=*((int*)arg);
    char name_msg[NAME_SIZE+BUF_SIZE];
    while(1) 
    {
        fgets(msg, BUF_SIZE, stdin);
        if(!strcmp(msg,"q\n")||!strcmp(msg,"Q\n")) 
        {
            close(sock);
            exit(0);
        }
        sprintf(name_msg,"%s %s", name, msg);  // 将name和msg合并成name_msg。
        write(sock, name_msg, strlen(name_msg)); // 传送到服务器。
    }
    return NULL;
}

void* recv_msg(void* arg)   // read thread main
{
    int sock=*((int*)arg);
    char name_msg[NAME_SIZE+BUF_SIZE];
    int str_len;
    while(1)
    {
        str_len=read(sock, name_msg, NAME_SIZE+BUF_SIZE-1);
        if(str_len==-1) 
            return (void*)-1;
        name_msg[str_len]=0;
        fputs(name_msg, stdout);
    }
    return NULL;
}

void error_handling(char *msg)
{
    fputs(msg, stderr);
    fputc('\n', stderr);
    exit(1);
}

运行结果:

运行结果∶chat_server.C
root@my_linux:/tcpip# gcc chat_serv.c -D_REENTRANT -o cserv -lpthread     //  -D_REENTRANT用于线程安全函数。
root@my_linux:/tcpip# ./cserv 9190 
Connected client IP: 127.0.0.1 
Connected client IP: 127.0.0.1 
Connected client IP: 127.0.0.1
    
运行结果∶ chat_clnt.c One [From Yoon]
root@my_linux:/tcpip# gcc chat_clnt.c -D_REENTRANT -o cclnt -lpthread 
root@my_linux:/tcpip# ./cclnt 127.0.0.1 9190 Yoon 
Hi everyone~
[Yoon] Hi everyone~
[Choi] Hi Yoon
[Hong] Hi~ friends
    
运行结果∶chat_clnt.c Two [From Choi]
root@my_linux:/tcpip#./cclnt 127.0.0.1 9190 Choi 
[Yoon] Hi everyone
~Hi Yoon 
[Choi] Hi Yoon 
[Hong] Hi~ friends
    
运行结果∶chat_clnt.c Three [From Hong]
root@my_linux:/tcpip# ./cclnt 127.0.0.1 9190 Hong 
[Yoon] Hi everyone~
[Choi] Hi Yoon 
Hi~ friends 
[Hong] Hi~ friends

第24章:制作HTTP服务器端

HTTP

HTTP:超文本传输协议。

HTTP 是 Hypertext Transfer Protocol 的缩写,Hypertext(超文本)是可以根据客户端请求而跳转的结构化信息。例如,通过浏览器访问图灵社区的主页时,首页文件将传输到浏览器并展现给大家,此时各位可以点击鼠标跳转到任意页面。这种可跳转的文本(Text)称为超文本。 HTTP 是以超文本传输为目的而设计的应用层协议,这种协议同样属于基于 TCP/IP 实现的协议,因此,我们也可以直接实现 HTTP。从结果上看,实现该协议相当于实现 Web 服务器端。另外,浏览器也属于基于套接字的客户端,因为连接到任意 Web 服务器端时,浏览器内部也会创建套接字。只不过浏览器多了一项功能,它将服务器端传输的 HTML 格式的超文本解析为可读性较强的视图。

无状态的Stateless协议

服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称"无状态的 Stateless 协议"。

为了弥补 HTTP 无法保持连接的缺点,Web 编程中通常会使用 Cookie 和 Session 技术。购物网站的购物车功能,即使关闭浏览器也不会丢失购物车内的信息(甚至不用登录)。这种保持状态的功能都是通过 Cookie 和 Session 技术实现的。

请求消息的结构

Web 服务器端需要解析并响应客户端请求,客户端和服务器端之间的数据请求方式标准如图所示。

请求消息可以分为请求行、消息头、消息体等 3 个部分。

请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST,GET 主要用于请求数据,POST 主要用于传输数据。

GET/index.html HTTP/1.1具有如下含义: "请求(GET)index.html 文件,希望以 1.1 版本的 HTTP 协议进行通信。"

请求行只能通过 1 行(line)发送。

消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。

消息体中装有客户端向服务器端传输的数据,为了装入数据,需要以 POST 方式发送请求。但我们的目标是实现 GET 方式的服务器端,所以可以忽略这部分内容。消息体和消息头之间以空行分开,因此不会发生边界问题。

响应消息的结构

该响应消息由状态行、头信息、消息体等 3 个部分构成。状态行中含有关于请求的状态信息,这是其与请求消息相比最为显著的区别。

状态行中含有关于客户端请求的处理结果。例如,客户端请求 index.html 文件时,表示 index.html 文件是否存在、服务器端是否发生问题而无法响应等不同情况的信息将写入状态行。

"HTTP/1.1200 OK"具有如下含义∶ "我想用 HTTP1.1 版本进行响应,你的请求已正确处理(200 OK)。"

表示"客户端请求的执行结果"的数字称为状态码,典型的有以下几种:

  1. 200 OK∶成功处理了请求!

  2. 404 Not Found∶请求的文件不存在!

  3. 400 Bad Request∶请求方式错误,请检查!

消息头中含有传输的数据类型和长度等信息。

图中的消息头含有如下信息∶ "服务器端名为 SimpleWebServer,传输的数据类型为 text/html(html 格式的文本数据)。数据长度不超过 2048 字节。"

最后插入1个空行后,通过消息体发送客户端请求的文件数据。

实现简单的 Web 服务器端

// webserv_linux.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>

#define BUF_SIZE 1024
#define SMALL_BUF 100

void* request_handler(void* arg);
void send_data(FILE* fp, char* ct, char* file_name);
char* content_type(char* file);
void send_error(FILE* fp);
void error_handling(char* message);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    int clnt_adr_size;
    char buf[BUF_SIZE];
    pthread_t t_id;	
    if(argc!=2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    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, 20)==-1)
        error_handling("listen() error");

    while(1)
    {
        clnt_adr_size=sizeof(clnt_adr);
        clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_size);
        printf("Connection Request : %s:%d\n", inet_ntoa(clnt_adr.sin_addr), ntohs(clnt_adr.sin_port));
        pthread_create(&t_id, NULL, request_handler, &clnt_sock);
        pthread_detach(t_id);
    }
    close(serv_sock);
    return 0;
}

void* request_handler(void *arg)
{
    int clnt_sock=*((int*)arg);
    char req_line[SMALL_BUF];
    FILE* clnt_read;
    FILE* clnt_write;

    char method[10];
    char ct[15];
    char file_name[30];

    clnt_read=fdopen(clnt_sock, "r");
    clnt_write=fdopen(dup(clnt_sock), "w");
    fgets(req_line, SMALL_BUF, clnt_read);	
    if(strstr(req_line, "HTTP/")==NULL)   // 返回第一次出现"HTTP/"及之后的字符的地址。
    {
        send_error(clnt_write);
        fclose(clnt_read);
        fclose(clnt_write);
        return;
    }

    strcpy(method, strtok(req_line, " /"));  // strtok下面介绍
    strcpy(file_name, strtok(NULL, " /"));
    strcpy(ct, content_type(file_name));
    if(strcmp(method, "GET")!=0)
    {
        send_error(clnt_write);
        fclose(clnt_read);
        fclose(clnt_write);
        return;
    }

    fclose(clnt_read);
    send_data(clnt_write, ct, file_name); 
}

void send_data(FILE* fp, char* ct, char* file_name)
{
    char protocol[]="HTTP/1.0 200 OK\r\n";
    char server[]="Server:Linux Web Server \r\n";
    char cnt_len[]="Content-length:2048\r\n";
    char cnt_type[SMALL_BUF];
    char buf[BUF_SIZE];
    FILE* send_file;

    sprintf(cnt_type, "Content-type:%s\r\n\r\n", ct);
    send_file=fopen(file_name, "r");
    if(send_file==NULL)
    {
        send_error(fp);
        return;
    }

    
    fputs(protocol, fp);
    fputs(server, fp);
    fputs(cnt_len, fp);
    fputs(cnt_type, fp);

    
    while(fgets(buf, BUF_SIZE, send_file)!=NULL) 
    {
        fputs(buf, fp);
        fflush(fp);
    }
    fflush(fp);
    fclose(fp);
}

char* content_type(char* file)
{
    char extension[SMALL_BUF];
    char file_name[SMALL_BUF];
    strcpy(file_name, file);
    strtok(file_name, ".");
    strcpy(extension, strtok(NULL, "."));

    if(!strcmp(extension, "html")||!strcmp(extension, "htm")) 
        return "text/html";
    else
        return "text/plain";
}

void send_error(FILE* fp)
{	
    char protocol[]="HTTP/1.0 400 Bad Request\r\n";
    char server[]="Server:Linux Web Server \r\n";
    char cnt_len[]="Content-length:2048\r\n";
    char cnt_type[]="Content-type:text/html\r\n\r\n";
    char content[]="<html><head><title>NETWORK</title></head>"
        "<body><font size=+5><br>发生错误! 查看请求文件名和请求方式!"
        "</font></body></html>";

    fputs(protocol, fp);
    fputs(server, fp);
    fputs(cnt_len, fp);
    fputs(cnt_type, fp);
    fflush(fp);
}

void error_handling(char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

首先启动该服务器端,再启动 Web 浏览器进行连接。

gcc webserv_linux.c  -D_REENTRANT -o w -lpthread 
./w 9190

// strtok

/*
头文件:#include <string.h>

定义函数:char * strtok(char *s, const char *delim);

函数说明:strtok()用来将字符串分割成一个个片段。参数s 指向欲分割的字符串,参数delim 则为分割字符串,当strtok()在参数s 的字符串中发现到参数delim 的分割字符时则会将该字符改为\0 字符。在第一次调用时,strtok()必需给予参数s 字符串,往后的调用则将参数s 设置成NULL。每次调用成功则返回下一个分割后的字符串指针。

返回值:返回下一个分割后的字符串指针,如果已无从分割则返回NULL。
*/
int main(){
 char s[] = "ab-cd : ef;gh :i-jkl;mnop;qrs-tu: vwx-y;z";
 char *delim = "-: ";
 char *p;
 printf("%s ", strtok(s, delim));  // 输出ab
 while((p = strtok(NULL, delim)))
     printf("%s ", p);
 printf("\n");
}

/*
ab cd ef;gh i jkl;mnop;qrs tu vwx y;z     //-与:字符已经被\0 字符取代
*/



图解 TCP/IP

OSI 模型

通信举例:

传输方式:

  1. 面向连接、面向消息

  2. 电路交换、分组交换

  3. 多播、广播、单播

浏览器与服务端之间通信所用的协议是 HTTP。所传输数据的主要格式是 HTML (HyperText Markup Language)。WWW 中的 HTTP 属于 OSI 应用层的协议,而 HTML 属于表示层的协议。

网络层与数据链路层的关系

数据链路层提供直连两个设备之间的通信功能。与之相比,作为网络层的 IP 则负责在没有直连的两个网络之间进行通信传输。那么为什么一定需要这样的两个层次呢?它们之间的区别又是什么呢?

我们以旅行为例说明这个问题。有个人要去一个很远的地方旅行,并且计划先后乘坐飞机、火 车、公交车到达目的地。为此,他决定先去旅行社购买机票和火车票。

旅行社不仅为他预订好了旅途过程中所需要的机票和火车票,甚至为他制定了一个详细行程表,详细到 几点几分需要乘坐飞机或火车都一目了然。

当然,机票和火车票只有特定区间内有效,当你换乘不 同公司的飞机或火车时,还需要重新购票。

仔细分析一下机票和火车票,不难发现,每张票只能够在某一限定区间内移动。此处的“区间内”就如同通信网络上的数据链路。而这个区间内的出发地点和目的地点就如同某一个数据链路的源地址和目标地址等首部信息(出发地点好比源MAC地址,目标地点好比目的MAC地址) 。整个全程的行程表的作用就相当于网络层。

如果我们只有行程表而没有车票,就无法搭乘交通工具到达目的地。反之,如果除了车票其他什么都没有,恐怕也很难到达目的地。因为你不知道该坐什么车,也不知道该在哪里换乘。与之类似,计算机网络中也需要数据链路层和网络层这个分层才能实现向最终目标地址的通信。

IP地址



图解HTTP

IP 地址和 MAC 地址

IP 协议的作用是把各种数据包传送给对方。而要保证确实传送到对方那里,则需要满足各类条件。其中两个重要的条件是IP 地址和 MAC 地址。

IP 地址指明了节点被分配到的地址,MAC 地址是指网卡所属的固定地址。IP 地址可以和 MAC 地址进行配对。IP 地址可变换,但 MAC 地址基本上不会更改。

IP 间的通信依赖 MAC 地址。在网络上,通信的双方在同一局域网内的情况是很少的,通常是经过多台计算机和网络设备中转才能连接到对方。而在进行中转时,会利用下一站中转设备的 MAC 地址来搜索下一个中转目标。这时,会采用 ARP 协议(Address Resolution Protocol)。ARP 是一种用以解析地址的协议,根据通信方的 IP 地址就可以反查出对应的 MAC 地址。

各协议作用

请求和响应

 

 

HTTP/1.1 虽然是无状态协议,但为了实现期望的保持状态功能,于是引入了 Cookie 技术。有了 Cookie 再用 HTTP 协议通信,就可以管理状态了。

方法对应的协议

使用 Cookie 的状态管理

HTTP 是无状态协议,它不对之前发生过的请求和响应的状态进行管理。 假设要求登录认证的 Web 页面本身无法进行状态的管理(不记录已登录的状态),那么每次跳转新页面不是要再次登录,就是要在每次请求报文中附加参数来管理登录状态。 不可否认,无状态协议当然也有它的优点。由于不必保存状态,自然可减少服务器的 CPU 及内存资源的消耗。

工作过程:

Cookie 会根据从服务器端发送的响应报文内的一个叫做 Set-Cookie 的首部字段信息,通知客户端保存 Cookie。当下次客户端再往该服务器发送请求时,客户端会自动在请求报文中加入 Cookie 值后发送出去。 服务器端发现客户端发送过来的 Cookie 后,会去检查究竟是从哪一个客户端发来的连接请求,然后对比服务器上的记录,最后得到之前的状态信息。

 

 

HTTP报文

报文结构

通常,并不一定要有报文主体。

 

报文主体和实体主体的差异:

●报文(message) 是 HTTP 通信中的基本单位,由 8 位组字节流组成,通过 HTTP 通信传输。

●实体(entity) 作为请求或响应的有效载荷数据(补充项)被传输,其内容由实体首部和实体主体组成。

HTTP 报文的主体用于传输请求或响应的实体主体。 通常,报文主体等于实体主体。只有当传输中进行编码操作时,实体主体的内容发生变化,才导致它和报文主体产生差异。

编码提升传输速率

  1. 压缩传输的内容编码

    内容编码指明应用在实体内容上的编码格式,并保持实体信息原样压缩。内容编码后的实体由客户端接收并负责解码。

  2. 分割发送的分块传输编码 请求的编码实体资源尚未全部传输完成之前,浏览器无法显示请求页面。在传输大容量数据时,通过把数据分割成多块,能够让浏览器逐步显示页面。

发送多种数据的多部分对象集合

发送邮件时,我们可以在邮件里写入文字并添加多份附件。这是因为采用了MIME(Multipurpose Internet Mail Extensions,多用途因特网邮件扩展)机制,它允许邮件处理文本、图片、视频等多个不同类型的数据。

获取部分内容的范围请求

以前,用户不能使用现在这种高速的带宽访问互联网,当时,下载一个尺寸稍大的图片或文件就已经很吃力了。如果下载过程中遇到网络中断的情况,那就必须重头开始。为了解决上述问题,需要一种可恢复的机制。所谓恢复是指能从之前下载中断处恢复下载。

要实现该功能需要指定下载的实体范围。

执行范围请求时,会用到首部字段 Range 来指定资源的 byte 范围。byte 范围的指定形式如下。

内容协商返回最合适的内容

同一个 Web 网站有可能存在着多份相同内容的页面。比如英语版和中文版的 Web 页面,它们内容上虽相同,但使用的语言却不同。 当浏览器的默认语言为英语或中文,访问相同 URI 的 Web 页面时,则会显示对应的英语版或中文版的 Web 页面。这样的机制称为内容协商(Content Negotiation )。

返回结果的状态码

 

在响应报文内,随状态码一起返回的信息会因方法的不同而发生改变。比如,使用 GET 方法时,对应请求资源的实体会作为响应返回;而使用 HEAD 方法时,对应请求资源的实体首部不随报文主体作为响应返回(即在响应中只返回首部,不会返回实体的主体部分)。

200 OK

204 No Content

当从浏览器发出请求处理后,返回 204 响应,那么浏览器显示的页面不发生更新。 一般在只需要从客户端往服务器发送信息,而对客户端不需要发送新信息内容的情况下使用。

206 Partial Content

301 Moved Permanently

永久性重定向。该状态码表示请求的资源已被分配了新的 URI,以后应使用资源现在所指的 URI。也就是说,如果已经把资源对应的 URI 保存为书签了,这时应该按 Location 首部字段提示的 URI 重新保存。

302 Found

临时性重定向。该状态码表示请求的资源已被分配了新的 URI,希望用户(本次)能使用新的 URI 访问。

303 See Other

303 状态码和 302 Found 状态码有着相同的功能,但 303 状态码明确表示客户端应当采用 GET 方法获取资源,这点与 302 状态码有区别。

304 Not Modified

304 虽然被划分在 3XX 类别中,但是和重定向没有关系。

307 Temporary Redirect

临时重定向。该状态码与 302 Found 有着相同的含义。尽管 302 标准禁止 POST 变换成 GET,但实际使用时大家并不遵守。 307 会遵照浏览器标准,不会从 POST 变成 GET。但是,对于处理响应时的行为,每种浏览器有可能出现不同的情况。

400 Bad Request

该状态码表示请求报文中存在语法错误。当错误发生时,需修改请求的内容后再次发送请求。另外,浏览器会像 200 OK一样对待该状态码。

401 Unauthorized

该状态码表示发送的请求需要有通过 HTTP 认证(BASIC认证、DIGEST认证)的认证信息。另外若之前已进行过 1 次请求,则表示用户认证失败。 返回含有 401 的响应必须包含一个适用于被请求资源的 WWW-Authenticate 首部用以质询(challenge)用户信息。当浏览器初次接收到 401 响应,会弹出认证用的对话窗口。

403 Forbidden

404 Not Found

500 Internal Server Error

503 Service Unavailable

该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。如果事先得知解除以上状况需要的时间,最好写入 Retry-After 首部字段再返回给客户端。

与 HTTP 协作的 Web 服务器

HTTP 通信时,除客户端和服务器以外,还有一些用于通信数据转发的应用程序,例如代理、网关和隧道。它们可以配合服务器工作。这些应用程序和服务器可以将请求转发给通信线路上的下一站服务器,并且能接收从那台服务器发送的响应再转发给客户端。

  1. 代理 代理是一种有转发功能的应用程序,它扮演了位于服务器和客户端"中间人"的角色,接收由客户端发送的请求并转发给服务器,同时也接收服务器返回的响应并转发给客户端。

    代理服务器的基本行为就是接收客户端发送的请求后转发给其他服务器。代理不改变请求 URI,会直接发送给前方持有资源的目标服务器。

    • 缓存代理 代理转发响应时,缓存代理(Caching Proxy)会预先将资源的副本(缓存)保存在代理服务器上。 当代理再次接收到对相同资源的请求时,就可以不从源服务器那里获取资源,而是将之前缓存的资源作为响应返回。

      客户端浏览器的缓存:

      缓存不仅可以存在于缓存服务器内,还可以存在客户端浏览器中。以Intermet Explorer程序为例,把客户端缓存称为临时网络文件(Temporary Internet File)。 浏览器缓存如果有效,就不必再向服务器请求相同的资源了,可以直接从本地磁盘内读取。 另外,和缓存服务器相同的一点是,当判定缓存过期后,会向源服务器确认资源的有效性。若判断浏览器缓存失效,浏览器会再次请求新资源。

    • 透明代理 转发请求或响应时,不对报文做任何加工的代理类型被称为透明代理(Transparent Proxy)。反之,对报文内容进行加工的代理被称为非透明代理。

  2. 网关 网关是转发其他服务器通信数据的服务器,接收从客户端发送来的请求时,它就像自己拥有资源的源服务器一样对请求进行处理。有时客户端可能都不会察觉,自己的通信目标是一个网关。

    利用网关能提高通信的安全性,因为可以在客户端与网关之间的通信线路上加密以确保连接的安全。比如,网关可以连接数据库,使用 SQL 语句查询数据。另外,在 Web 购物网站上进行信用卡结算时,网关可以和信用卡结算系统联动。

  3. 隧道 隧道是在相隔甚远的客户端和服务器两者之间进行中转,并保持双方通信连接的应用程序。

    隧道可按要求建立起一条与其他服务器的通信线路,届时使用 SSL 等加密手段进行通信。隧道的目的是确保客户端能与服务器进行安全的通信。 隧道本身不会去解析 HTTP 请求。也就是说,请求保持原样中转给之后的服务器。隧道会在通信双方断开连接时结束。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值