赞
踩
问题:
write函数 第二个参数,为什么转换成char*形式?
自己理解:char大小为1,转换成char指针,表示从第一个字节开始,第三个参数表示传递大小——字节数。与地址指针对应
传输数据时,数据的格式是什么?
9.2中用SO_REUSEADDR后,Time_Wait状态时端口号被分配给其他套接字,但是最后的ACK消息没有正常传到主机B,此时B进行FIN消息重传,会不会传到其他位置(端口号已经分配给其他套接字)???(重传时是否还需要端口号??)
socket——》安装电话机;
bind——》分配电话号码;
listen——》连接电话线;
accept——》拿起话筒;
connect——》打电话。
**文件描述符:**系统分配给文件或套接字的整数。(一种标识)
LINUX->描述符;windows ->句柄
0、1、2是分配给标准I/O的描述符;然后从3开始以由小到大的顺序编号。
int open(const char *path, int flag);
// path 文件名的字符串地址; flag 文件打开模式信息 --> 成功时返回文件描述符,失败时返回-1。
#include <unistd.h>
int close(int fd); // fd 需要关闭的文件或套接字的文件描述符。--> 成功时返回0,失败时返回-1。
ssize_t write(int fd, const void* buf, size_t nbytes);// fd 显示数据传输对象的文件描述符;buf 保存要传输数据的缓冲地址值。
// nbytes 要传输数据的字节数 --> 成功时返回写入的字节数,失败时返回-1。
ssize_t read(int fd, void *buf, size_t nbytes); // fd 显示数据接收对象的文件描述符;buf 要保存接收数据的缓冲地址值;
//nbytes 要接收数据的最大字节数 --> 成功时返回接收字节数(但遇到文件结尾则返回0), 失败时返回-1。
//size_t表示unsigned int类型,ssize_t标识signed int类型
//* 数据类型_t 表示元数据类型,为操作系统定义的, 在 sys/types.h文件中一般由typedef声明定义
协议:计算机间对话必备通信规则。
创建套接字
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
//--> 成功时返回文件描述符,失败时返回-1。
//domain 套接字中使用的协议族(Protocol Family)信息;
//type 套接字数据传输类型信息;
//protocol 计算机通信中使用的协议信息。
套接字实际采用的最终协议信息是通过第三个参数传递的,在指定的协议族范围内通过第一个参数决定第三个参数。
套接字类型(Type)
指的是套接字的数据传输方式。 协议族中存在多种数据传输方式。
Type1:面向连接的套接字(SOCK_STREAM)
面向连接的套接字除特殊情况外不会发生数据丢失。
特征:传输过程中数据不会消失;
按序传输数据;
传输的数据不存在数据边界(Boundary)。
**“传输数据的计算机通过3次调用write函数传递了100字节的数据,但接收数据的计算机仅通过1次read函数的调用就接收了全部100字节。” **
收发数据的套接字内部有缓冲(buffer),简言之就是字节的数组。通过套接字传输的数据将保到该数组。因此,收到数据并不意味着马上调用read函数。 只要不超过数组容量,则有可能在数据填充满缓冲后通过一次read函数调用读取全部,也有可能分成多次read函数调用进行读取。也就是说,在面向连接的套接字中,read函数和write函数的调用次数并无太大意义。
“接字连接必须是一一对应”
“可靠的、按需传递的、基于字节的面向连接的数据传输方式的套接字”
Type2:面向消息的套接字(SOCK_DGRAM)
强调快速传输而非传输顺序。
传输的数据可能丢失也可能损毁。
传输的数据有数据边界。——》意味着接收数据的次数应和传输次数相同。
限制每次传输的数据大小。
特性:“不可靠、不按顺序传递的、以数据的高速传输为目的的套接字”
协议的最终选择
前两个参数即可创建所需的套接字,所以大部分情况下可以向第三个参数传递0,除非遇到:“同一协议族中存在多个数据传输方式相同的协议”
即数据传输方式相同,但协议不同。便需要第三个参数指定协议信息。
IP 是为收发网络数据而分配给计算机的值。
端口号是为区分程序中创建的套接字而分配给套接字的序号 。
先浏览IP地址的网络地址,把数据传到相应的网络;再浏览数据的**主机地址(主机ID)**并将数据传给目标计算机。
用于区分套接字的端口号
利用端口号才能将数据传输到相应目的应用程序
计算机中一般配有NIC(Network Interface Card,网络接口卡(网络适配器、网卡))数据传输设备。通过NIC向计算机内部传输数据时会用到IP。
操作系统负责把传递到内部的数据适当分配套接字,这时就要利用端口号。也就是说,通过NIC接收的数据内有端口号,操作系统正是参考此端口号把数据传输给相应端口的套接字。
端口号就是在同一操作系统内为区分不同套接字而设置的,因此无法将1个端口号分配给不同的套接字。另外,端口号由16位构成,可分配端口号范围从0-65535。但0-1023是知名端口(Well-known PORT),一般分配给特定应用程序,所以应当分配范围之外的值。
端口不能重复,但TCP套接字和UDP套接字不会公用端口号,所以允许重复。
应用程序中使用的IP地址和端口号以结构体的形式给出定义。
表示IPv4地址的结构体
struct sockaddr_in
{
sa_family_t sin_family; //地址族(Address Family)
uint16_t sin_port; //16位TCP/UDP端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用
};
该结构体中提到的另一个结构体in_addr定义如下,它用来存放32位IP地址。
struct in_addr
{
in_addr_t s_addr; //32位IPv4地址
};
数据类型名称 | 数据类型说明 | 声明的头文件 |
---|---|---|
int 8_t | signed 8-bit int | sys/types.h |
uint8_t | unsigned 8-bit int (unsigned char) | sys/types.h |
int16_t | signed 16-bit int | sys/types.h |
uint16_t | unsigned 16-bit int (unsigned short) | sys/types.h |
int32_t | signed 32-bit int | sys/types.h |
uint32_t | unsigned 32-bit int (unsigned long) | sys/types.h |
sa_family_t | 地址族(address family) | sys/socket.h |
socklen_t | 长度(length of struct) | sys/socket.h |
in_addr_t | IP地址,声明为 uint32_t | netinet/in.h |
in_port_t | 端口号,声明为 uint16_t | netinet/in.h |
结构体 socket_in 的成元分析
成员sin_family
每种协议族适用的地址族均不同。
地址族(Address Family) | 含义 |
---|---|
AF_INET | IPV4用的地址族 |
AF_INET6 | IPV6用的地址族 |
AF_LOCAL | 本地通信中采用的 Unix 协议的地址族 |
成员sin_port
保存16位端口号。以网络字节序保存
成员sin_addr
保存32位IP地址信息,也是以网络字节序保存
成员sin_zero
无特殊含义。只是结构体sockaddr_in的大小与sockeaddr结构体保持一致而插入的成员。必须填充为0,否则无法得到想要的结果。
实际上,bind函数的第二个参数期望得到sockaddr结构体变量地址值,包括地址族、端口号、IP地址等。从下列代码也可以看出,直接向sockaddr结构体填充这些信息会带来麻烦。
struct sockaddr
{
sa_family_t sin_family; // 地址族(Address Family)
char sa_data[14]; // 地址信息
};
此结构体成员sa_data保存的地址信息中需包含IP地址和端口号,剩余部分应填充0,这也是bind函数要求的。而这对于包含地址信息来讲非常麻烦,继而就有了新的结构体sockaddr_in。若按照之前的讲解填写sockaddr_in结构体,则将生成符合bind函数要求的字节流。最后转换为sockaddr型的结构体变量,再传递给bind函数即可。
sockaddr_in与sockaddr二者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。
一般先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数:sockaddr_in用于socket定义和赋值;sockaddr用于函数参数。
不同的CPU中,4字节整数型值1在内存空间的保存方式是不同的。
00000000 00000000 00000000 00000001 大端序—》高位字节存放到低位地址。
有些CPU以这种顺序保存到内存,另外一些CPU则以倒序保存。
00000001 00000000 00000000 00000000 小端序—》高位字节存放到高位地址
字节序与网络字节序
代表CPU数据保存方式的主机字节序(Host Byte Order)在不同CPU中各不相同。目前主流的CPU以小端序方式保存数据。
在通过网络传输数据时约定统一方式,这种约定称为==网络字节序(Network Byte Order),非常简单——统一为大端序==。即,先把数据数组转换为大端序格式再进行网络传输。
因此,所有计算机接收数据时应识别该数据是网络字节序格式,小端序系统传输数据时应转化为大端序排列方式。
字节序转换
//转换字节序的函数。
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);
//h代表主机(host)字节序,n代表网络(network)字节序,s指的是short,l指的是long;
//htons是 h、to、n、s的组合,也可以解释为“把short型数据从主机字节序转化为网络字节序”。
//ntohs,也可以解释为“把short型数据从网络字节序转化为主机字节序”。
//通常,以s作为后缀的函数中,s代表2个字节short,因此用于端口号转换;以l作为后缀的函数中,l代表4个字节,因此用于IP地址转换。
将字符串信息转换为网络字节序的整数型
sockaddr_in中保存地址信息的成员为32位整数型。因此,为了分配IP地址,需要将其表示为32位整数型数据。
201.211.214.36转换为4字节整形数据。IP地址是点分十进制表示法,而非整数型数据表示法。sockaddr中IP地址是字符串形式
有个函数会帮我们将字符串形式的IP地址转换成32位整形数据。此函数在转换类型的同时进行网络字节序转换。
#include <arpa/inet.h>
in_addr_t inet_addr(const char * string);//--> 成功时返回32位大端序(网络字节序)整数型值,失败时返回 INADDR_NONE。
inet_addr函数不仅可以把IP地址转成32位整数型,而且还可以检测无效的IP地址。
inet_aton函数与inet_addr函数在功能上完全相同,也将字符串形式IP地址转换为32位网络字节序整数并返回。只不过该函数利用了in_addr结构体,且其使用频率更高。
#include <arpa/inet.h>
int inet_aton(const char * string, struct in_addr * addr); // string 含需转换的IP地址信息的字符串地址值;addr 将保存转换结果的in_addr结构体变量的地址值。
//--> 成功时返回1(true),失败时返回0(false)
//调用过程
char * addr="127.232.124.79";
struct sockaddr_in addr_inet;
inet_aton(addr,&addr_inet.sin_addr);
与inet_aton函数正好相反的函数,此函数可以把网络字节序整数型IP地址转换成我们熟悉的字符串形式。(网络字节序整数型——》字符串)
#include <arpa/inet.h>
char * inet_ntoa(struct in_addr adr);//--> 成功时返回转换的字符串地址值,失败时返回-1。
返回值为字符串地址意味着字符串已保存到内存空间(内部申请了内存并保存了字符串)。若再次调用inrt_ntoa函数,则可能覆盖之前保存的字符串信息,所以若需要长期保存,则应将字符串复制到其他内存空间。
网络地址初始化
struct sockaddr_in addr;
char* ser_ip = "211.217.168.13"; // 声明 IP 地址字符串
char* serv_port = "9190"; // 声明端口号字符串
memset(&addr, 0, sizeof(addr)); // 结构体变量addr的所有成员初始化为 0
addr.sin_family = AF_INET; // 指定地址族
addr.sin_addr.s_addr = inet_addr(serv_ip); // 基于字符串的IP地址初始化
addr.sin_port = htons(atoi(serv_port)); // 基于字符串的端口号初始化
memset函数将每个字节初始化为同一值:第一个参数为结构体变量addr的地址值,即初始化对象为addr;第二个参数为0,因此初始化为0;最后一个参数中传入addr的长度,因此addr的所有字节均初始化为0。这么做是为了将sockaddr_in结构体的成员sin_zero初始化为0。
atoi函数:(字符串——》整数型)
总之,上述代码利用字符串格式的IP地址和端口号初始化了sockaddr_in结构体变量。
客户端地址信息初始化
上述网络地址信息初始化过程主要针对服务器端。给套接字分配IP地址和端口号主要时为了下面这件事做准备:
“请把进入IP 211.217.168.13、9190端口的数据传给我!”
反观客户端中连接请求如下:
“请连接到IP 211.217.168.13、9190端口!”
请求方法不同意味着调用的函数也不同。服务器端的准备工作通过bind函数完成,而客户端则通过connect函数完成。
因此,函数调用前需准备的地址值类型也不同。服务器端声明sockaddr_in结构体变量,将其初始化为为赋予服务器端IP和套接字的端口号,然后调用bind函数;而客户端则声明sockaddr_in结构体,并初始化为要与之连接的服务器端套接字的IP和端口号,然后调用connect函数。
INADDR_ANY
每次创建服务器端套接字都要输入IP地址会有些繁琐,此时可如下初始化地址信息。
struct sockaddr_in addr;
char * serv_port = "9190";
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(atoi(serv_port));
与之前方式最大的区别在于,利用常数INADDR_ANY分配服务器端的IP地址。若采用这种方式,则可自动获取运行服务器端的计算机IP地址,不必亲自输入。 若同一计算机中已分配多个IP地址,只要端口号一致,就可以从不同IP地址接收数据。
实际IP地址个数==计算机中安装的NIC数量,即使是服务器套接字,也需要决定应接受哪个IP传来(哪个NIC)的数据。若只有1个NIC,则直接使用INADDR_ANY
第1章的 hello_server.c、 hello_client.c运行过程
./hserver 9190
通过代码可知,向main函数传递9190为端口号。通过此端口号创建服务器端套接字并运行程序,但未传递IP地址,因为可以通过INADDR_ANY指定IP地址。
./hclient 127.0.0.1 9190
127.0.0.1是回送地址(loopback address),指的是计算机自身IP地址。
如果服务器端和客户端分别在2台计算计中运行,则可以输入服务器端IP地址。
向套接字分配网络地址
前面讨论了sockaddr_in结构体的初始化方法,接下来就把初始化的地址信息分配给套接字。==bind函数==负责这项操作。
#include<sys/socket.h>
int bind(int sockfd, struct sockaddr_in * myaddr, socklen_t addrlen); //--> 成功时返回0, 失败时返回-1。 // sockfd: 要分配地址信息(IP地址和端口号)的套接字文件描述符。 // myaddr: 存有地址信息的结构体变量地址值。
// addrlen: 第二个结构体变量的长度。
//如果此函数调用成功,则将第二个参数指定的地址信息分配给第一个参数中的相应套接字。
TCP/IP协议栈
各层可能通过操作系统等软件实现,也可能通过类似NIC的硬件设备实现。
链路层
利用MAC地址在同网内传输,可主机——》路由器
IP层——网络层
利用IP地址在不同网之间传输数据。为了复杂的网络传输中传输数据,首先需要考虑路径的选择。
IP本身是面向消息的、不可靠的协议。
TCP/UDP层——传输层
IP层解决数据传输中的路径选择问题,只需照此路径传输数据即可。TCP和UDP层以IP层提供的路径信息为基础完成实际的数据传输,故该层又称传输层 (Transport)。
TCP可以保证可靠的数据传输,TCP协议确认后向不可靠的IP协议赋予可靠性。
应用层
**上述内容是套接字通信过程中自动处理的。选择数据传输路径、数据确认过程都被隐藏到套接字内部。**程序员编程时无需考虑这些过程,但并不意味着不用掌握这些知识。只有掌握了这些理论,才能编写出符合需求的网络程序。
向各位提供的工具就是套接字,大家只需利用套接字编写出程序可。编写软件过程中,需要根据程序特点决定服务器和客户端之间的数据传输规则 (规定),这便是应用层协议。
网络编程的大部分内容就是设计并实现应用层协议。
进入等待连接请求状态
通过调用listen函数进入等待连接请求状态。只有调用了listen函数,客户端才能进入可发出连接请求的状态。换言之,这时客户端才能调用connect函数(若提前调用将发生错误)。
#include <sys/socket.h>
int listen(int sock, int backlog);
//--> 成功时返回0, 失败时返回-1。
// sock: 希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数成为服务器端套接字(监听套接字)。
// backlog: 连接请求等待队列(Queue)的长度,若为5,则队列长度为5,表示最多使5个连接请求进入队列。
受理客户端连接请求
受理请求意味着进入可接受数据的状态。进入这种状态所需的部件–套接字! 下面这个函数将自动创建套接字,并连接到发起请求的客户端。
#include <sys/socket.h>
int accept(int sock, struct sockaddr * addr, socklen_t * addrlen);
//--> 成功时返回创建的套接字文件描述符,失败时返回-1。
// sock: 服务器套接字的文件描述符
// addr: 保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量参数填充客户端地址信息。
// addrlen: 第二个参数addr结构体的长度,但是存有长度的变量地址。函数调用完成后,该变量即被填入客户端地址长度。
accept函数将产生用于数据I/O的套接字,并返回其文件描述符。需强调的是,套接字是自动创建的,并自动与发起连接请求的客户端建立连接。
TCP客户端的默认函数调用顺序
服务器端调用 listen函数后 创建连接请求等待队列,之后客户端即可请求连接。
#include <sys/socket.h>
int connect(int sock, struct sockaddr* servaddr, socklen_t addrlen);
//--> 成功时返回 0,失败时返回-1。
// sock: 客户端套接字文件描述符。
// servaddr: 保存目标服务器端地址信息的变量地址值。
// addrlen: 以字节为单元传递已传递给第二个结构体参数servaddr的地址变量长度。
客户端调用 connect函数后,发生以下情况之一会返回(返回函数调用)
服务器端接收连接请求。
发生断网等异常情况而中断连接请求。
需要注意,所谓的 “接收连接” 并不意味着服务器端调用 accept函数,其实是服务器端把连接请求信息记录到等待队列。因此connect函数返回后并不立即进行数据交换。
Tips: 服务器端给套接字分配了IP和端口号。但客户端实现过程中并未出现套接字地址分配,而是创建套接字后立即调用了connect函数。
1. **(何时)**调用connect函数时
2. **(何地)**在操作系统中,更准确的说是内核中分配地址
3. **(如何)**IP用计算机(主机)的IP,端口随机。
客户端的IP地址和端口号在调用connect函数时自动分配,无需调用标记的 bind函数进行分配。
基于TCP的服务器端/客户端函数调用关系
服务器端创建套接字后连续调用bind、listen函数进入等待状态,客户端通过调用connect函数发起连接请求。
需要注意的是,客户端智能等到服务器端调用listen函数后才能调connect函数。同时要清楚,客户端调用connnect函数前,服务器端才有可能率先调用accept函数。此时服务器端在调用accept函数时进入阻塞(blocking)状态,直到客户端调用connect函数为止。
回声(echo)服务器端/客户端,即服务器端将客户端传输的字符串数据原封不动地传回客户端,就像回声一样。
实现迭代服务器端
调用accept函数后,紧接着调用I/O相关的read、write函数,然后调用close函数。这并非针对服务器端套接字,而是针对
accept函数调用时创建的套接字。
调用close函数就意味着结束了针对某一客户端的服务。此时如果还想服务于其他客户端,就要重新调用accept函数。
同一个时刻确实只能服务于一个客户端。将来学完进程和线程之后,就可以编写同时服务多个客户端的服务器了。
迭代回声服务器端/客户端
源码分析P83
回声客户端存在的问题
write(sock, message, strlen(message));
str_len = read(sock, message, strlen(message));
message[str_len] =0;
printf("Message from server: %s", message);
以上代码有个错误假设: “每次调用read、write函数时都会以字符串为单元执行实际的I/O操作。”
客户端是基于TCP的——》TCP不存在数据边界(一次read读取多次write传递的数据)。因此多次调用write函数传递的字符串有可能一次性传递到服务器端。此时客户端有可能从服务器端收到多个字符串。
“ 字符串太长了,需要分2个数据包发送!”
服务器端希望通过调用1次write函数传输数据,但如果数据太大,操作系统就有可能把数据分成多个数据包发送到客户端。 另外在此过程中,**客户端有可能在尚未收到全部数据包时就调用read函数。**
解决第4章上述的回声客户端存在的问题
回声服务器端没有问题,只有回声客户端有问题?
问题不在服务器端,而在客户端。
回声客户端传输的是字符串,而且是通过调用write函数一次性发送的。之后还调用一次read函数,期待着接收自己传输的字符串。这就是问题所在。
的确等段时间便可接收,但不确定需要等多久!理想的客户端在收到字符串数据时应立即读取并输出。
回升客户端问题解决办法
其实很容易解决,因为可以提前确定接收数据的大小。
若之前传输了20字节长的字符串,则在接收时循环调用read函数读取20个字节即可。
...
while(recv_len < str_len){
revc_cnt=read(aock,&message[recv_len],BUF_SIZE-1);
if(recv_cnt==-1){
error_handling("read() error!");
}
recv_len +=recv_cnt;
}
...
如果问题不在于回声客户端:定义应用层协议
回声客户端可以提前知道接受的数据长度,但是在大多数情况下这不太可能。此时需要的就是应用层协议的定义。
之前回声服务端/客户端中定义了如下协议 :“收到Q就立即终止连接”。同样,收发数据过程中也需要定好规则(协议)以表示数据的边界,或提前告知收发数据的大小。
服务器/客户端实现过程中逐步定义的这些规则集合就是 应用层协议。
计算器服务器端/客户端示例
源码分析P97
为理解套接字选项(第9章)的基础
TCP 套接字中的 I/O缓冲
write函数调用后并非立即传输数据,read函数调用后也并非马上接收数据。
I/O缓冲特性:
I/O缓冲在每个TCP套接字中单独存在
I/O缓冲在创建套接字时自动生成
即使关闭套接字也会继续输出缓冲中遗留的数据(输出不停)
关闭套接字将丢失输入缓冲中的数据(输入停止)
“不会发生超过输入缓冲大小的数据传输”,因为TCP会控制数据流:滑动窗口协议
因此TCP中不会因为缓冲溢出而丢失数据。
write函数在数据移到输出缓冲中时便返回,而不是在完成向对方主机传输数据时。
TCP 内部工作原理1: 与对方套接字的连接
TCP 套接字从创建到消失所经过程分为如下3步:
1.与对方套接字建立连接
2.与对方套接字进行数据交换
3.断开与对方套接字的连接
建立连接
三次握手
TCP 内部的工作原理2: 与对方主机的数据交换
三次握手完成数据交换准备,收发数据过程如下:
ACK号=SEQ号 + 传递的字节数 + 1
数据传输过程出现错误,会超时重传
TCP 的内部工作原理3: 断开与套接字的连接
四次握手:先由套接字A向套接字B传递断开连接的消息,套接字B发出确认收到的消息,然后套接字B传递可以断开连接的消息,套接字A同样发出确认消息。
FIN表示断开连接,双方各发送一次FIN消息后断开连接。
B向A传递两次ACK 5001,第二次是因为发送ACK消息后未接收到数据而重传
UDP套接字的特点
UDP:不可靠、结构简单、性能高
TCP 在不可靠的 IP层进行流控制,而 UDP就缺少这种流控制机制。流控制是区分 UDP和 TCP最重要的标志。
每次交换的数据量越大,TCP的传输速率就越接近UDP的传输速率。
UDP内部工作原理
UDP 最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP套接字。**(主机内部)**
UDP的高效实用
UDP 也具有一定的可靠性。
传递压缩文件——》TCP,因为压缩文件只要丢失一部分就很难解压。
多媒体数据(视频、音频等)——》UDP,因为丢失一部分没有太大问题。
提供实时服务——》UDP,因为速度非常重要,流控制就有些多余了。
如果收发的数据量小但需要频繁连接时,UDP比TCP高效。
UDP中的服务器端和客户端没有连接
无连接便不需要listen函数和accept函数。
只有创建套接字的过程和数据交换过程。
UDP服务器端和客户端均需1个套接字
收发信件时使用的邮筒可以比喻为UDP套接字。只要附近有一个邮筒,就可以通过它向任意地址寄出信件。
1个UDP套接字就能和多台主机通信。
基于UDP的数据I/O函数
TCP套接字将保持与对方套接字的连接。
但UDP不会保持连接状态(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的地址结构体变量长度 //接受UDP数据的函数 #include <sys/socket.h> ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t * addrlen); //UDP数据的发送端并不固定,因此该函数定义为可接收发送短信息的形式,也就是将同时返回UDP数据包中的发送端信息 //--> 成功时返回接收的字节数,失败时返回 -1 //sock 用于接收数据的UDP套接字文件描述符 //buff 保存接收数据的缓冲地址值 //nbytes 可接收的最大字节数,故无法超过参数buff所指的缓冲大小 //flags 可选项参数,若没有则传入0 //from 存有发送端地址信息的sockaddr结构体变量的地址值——》“原来没有,调用之后存放发送端信息。(自己理解)” //addrlen 保存参数from的结构体变量长度的变量地址值
基于UDP的回声服务器端/客户端
源码分析P115
recvfrom函数在调用同时获取数据传输端的地址,正是利用该地址将数据逆向重传
UDP 客户端套接字的地址分配
TCP客户端调用connnect函数自动完成IP和端口号的分配。首次调用sendto函数时自动分配IP和端口号,分配地址一直保持到程序结束。
验证UDP数据传输中存在数据边界
存在数据边界的UDP套接字
TCP数据传输中国不存在数据边界,这表示“数据传输过程中调用I/O函数的次数不具有任何意义” 。
UDP是具有数据边界的协议,**输入函数的调用次数和输出函数的调用次数完全一致。**才能保证接收全部已发送的数据
**UDP数据报:**UDP存在数据边界,1个数据包即可成为1个完整数据,因此称为数据报。
已连接(connected)UDP套接字与未连接(unconnected)UDP套接字
TCP套接字中需注册待传数据的目标IP和端口号,而UDP中则无需注册。因此通过sendto函数传递数据的过程大致分为3个阶段:
向UDP套接字注册目标IP和端口号;(类似建立传输数据的通道)
传输数据;
删除UDP套接字中注册的目标地址信息 (切断通道)
若同一发送端向同一接收端 发送多个数据包,可以1-2-2-2-3,建立已连接UDP套接字。
创建已连接UDP套接字
创建已连接UDP套接字的过程格外简单,只需针对UDP套接字调用connect函数。
sock = socket(PF_INT, SOCK_DGRAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = ...;
addr.sin_port = ...;
connect(sock, (struct sockaddr*) &addr, sizeof(addr));
UDP套接字调用connect函数并不意味着要与对方UDP套接字连接,这只是向UDP套接字注册目标IP和端口信息。
之后就与TCP套接字一样,每次调用sendto函数时只需要传输数据。因此已经指定了收发对象,所以不仅可以使用sendto、recvfrom函数,还可以使用write、read函数进行通信。
单方面断开连接带来的问题
Linux的close函数和windows的closesocket函数意味着完全断开连接。既无法传输数据,也不能接收数据。
为了解决这类问题,“只关闭一部分数据交换中使用流”(Half—close)的方法应运而生。只关闭流的一半:可以传输数据但无法接收,或可以接收数据但无法传输。
套接字和流(Stream)
建立连接后的可交换数据的状态——》“流形成的状态”。
每个主机有两个流:输入流、输出流。
“优雅地断开”就是断开其中一个流
针对优雅断开的 shutdown函数
#include <sys/socket.h>
int shutdown(int sock, int howto);
//--> 成功时返回 0, 失败时返回 -1。
//sock 需要断开的套接字文件描述符。
//howto 传递断开方式信息。
//第二个参数决定断开连接的方式
//SHUT_RD: 断开输入流 套接字无法接收数据,即使输入缓冲收到数据也会抹去。且无法调用输入相关函数。
//SHUT_WR: 断开输出流 中断输出流,输出缓冲中留有未传输的数据,则将传至目标主机。
//SHUT_RDWR: 同时断开I/O流 同时关闭I/O流。相当于分两次调用shutdown,一次以SHUT_RD为参数,一次以SHUT_WR为参数。
为何需要半关闭
EOF表示文件传输结束。
调用close函数的同时关闭I/O流,这样也会向对方发送EOF。但此时无法再接收对方传输的数据。这时需要调用shutdown函数,只关闭服务器端 的输出流(半关闭)。这样既可以发送EOF,同时又保留了输入流,可以接收对方的数据。
基于半关闭的文件传输程序
源码分析P131
DNS是对IP地址和域名进行互相转换的系统,其核心是DNS服务器。
什么是域名
提供网络服务的服务器端也是通过IP地址区分的,但几乎不可能以非常难记的IP地址形式交换服务端地址信息。
因此,将容易记、易表述的域名分配并取代IP地址。
DNS服务器
域名是赋予服务器端的虚拟地址,而非实际地址。
DNS服务器:将虚拟地址转化为实际地址
ping 域名 得到IP地址
nslookup 查看自己计算机中注册的默认DNS服务器地址
Linux中 nslookup + server
顶级DNS服务器——根DNS服务器
DNS是层次化管理的一种分布式数据库系统
程序中有必要使用域名吗?
域名更长久,IP地址可能会经常改变。所以程序中需要IP地址和域名之间的转换函数
利用域名获取IP地址
利用以下函数可以通过传递字符串格式的域名获取IP地址
#include <netdb.h> struct hostent *gethostbyname(const char *hostname); //--> 成功时返回 hostent结构体地址, 失败时返回 NULL指针。 struct hostent { char *h_name; //official name char **h_aliases; //alias list int h_addrtype; //host address type int h_length; //address length char **h_addr_list; //address list } //h_name: 该变量中存有官方域名(Official domain name)。官方域名各代表某一主页,但一些著名公司并未用官方域名注册。 //h_aliases: 可以通过多个域名访问同一主页。同一IP可以绑定多个域名,因此,除官方域名外还可以指定其他域名。这些信息可以通过h_aliases获得。 //h_addrtype: gethostbyname函数不仅支持IPv4,还支持IPv6。因此可以通过此变量获取保存在h_addr_list的IP地址的地址族信息。若是IPv4,此变量存有AF_INET。 //h_length: 保存IP地址长度。若是IPv4地址,因为是4字节,则保存4;IPv6时,因为是16字节,故保存16。 //h_addr_list: 这是最重要的成员。通过此变量以整数形式保存域名对应的IP地址。另外,用户较多的网站可能分配多个IP给同一域名,利用多个服务器进行负载均衡。此时同样可以通过此变量获取IP地址信息。
h_addr_list字符串指针数组中的元素实际指向的事(实际保存的是)in_addr结构体变量地址而非字符串。所以需要类型转换inet_ntoa函数(网络字节序整数型——》字符串)
利用IP地址获取域名
#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
使用时注意类型的转换
套接字具有不同特性
从表9-1可以看出,套接字可选项是分层的**。IPPROTO_IP层可选项是IP协议相关事项,IPPROTO_TCP层可选项是TCP协议相关的事项, SOL_SOCKET层是套接字相关的通用可选项。**
//读取套接字可选项 #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传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数 //更改可选项时调用的函数 #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传递的可选项信息的字节数
源码分析P152
用于验证套接字类型的 SO_TYPE是典型的只读可选项:套接字类型只能在创建时决定,以后不能再更改。
SO_SNDBUF & SO_RCVBUF
创建套接字将同时生成I/O缓冲。(自动创建)
SO_RCVBUF是输入缓冲大小相关的可选项,SO_SNDBUF是输出缓冲大小的相关可选项。
用这2个可选项既可以读取当前I/O缓冲大小, 也可以进行更改。
读取时用getsockopt函数,更改时用setsockopt函数
源码分析P153
注意:设置的缓冲大小,可能不会100%按照我们的请求设置,但也大致反映出了通过setsockopt函数设置的缓冲大小
重点:SO_REUSEADDR及其相关的Time-wait状态
发生地址分配错误(Binding Error)
**地址分配错误:**若先断开服务器端会出现问题。如果用同一端口号重新运行服务器端,将输出 "bind() error"消息,并且无法再次运行。
Time-wait状态:只有先断开连接的(先发送FIN消息的)主机才会经过 Time-wait状态。
因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait过程时, 相应端口是正在使用状态。因此,就像之前验证过得,bind函数调用过程中当然会发生错误。
先断开连接的套接字必然会经过 Time-wait过程。但无需考虑客户端 Time-wait状态。因为客户端套接字是任意指定的。
存在Time-wait状态的原因:假设主机A向主机B传输ACK消息后立即消除套接字。但是这条ACK消息在传递中丢失,未能传输给B。这时主机B会认为自己发送的FIN消息未能到达主机A,继续重传,此时主机A已完全终止状态,因此主机B永远无法收到从主机A最后传来的ACK消息。相反,若A的套接字处在Time-wait状态,则会向主机B重传最后的ACK消息,主机B也能正常终止。
地址再分配
Nagle算法
为防止因数据包过多而发生网络过载,Nagle应用于TCP层
右侧未使用Nagle算法,假设字符“N”到“e”依序传到输出缓冲。
“只有收到前一数据的ACK消息时,Nagle算法才发送下一数据”
TCP套接字默认使用Nagle算法交换数据,因此最大限度地进行缓冲,直到收到ACK。一般情况下,不使用Nagle算法可以提高传输速度。但如果无条件放弃使用Nagle算法,就会增加过多的网络流量,反而会影响传输。因此,未准确判断数据特性时不应禁用Nagle算法。
“Nagle算法使用与否在网络流量上差别不大,使用Nagle算法的传输速度更慢”
禁用方法:只需将套接字可选项 TCP_NODELAY改为1(真)即可。
//禁用
int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*) &optval, sizeof(opt_val));
//查看Nagle算法设置状态
int opt_val;
socklen_t opt_len;
opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*) &optval, &opt_len);
并发服务器端的实现方法
网络程序中数据通信时间比CPU运算时间占比更大,因此,向多个客户端提供服务是一种有效利用CPU的方式。
并发服务器端实现模型和方法:
理解进程(Process)**
进程——“占用内存空间的正在运行的程序。”
**进程ID **
无论进程是如何创建的,所有进程都会从操作系统分配到ID。此ID称为 “进程ID”,其值为大于2的整数。1要分配给操作系统启动后的(用于协助操作系统) 首个进程,因此用户进程无法得到ID值为1。
linux 中 ps当前运行的所有进程
通过调用fork函数创建进程
创建进程的方法很多,此处只介绍用于创建多线程服务器端的fork函数
#include <unistd.h>
pid_t fork(void); //--> 成功时返回进程ID, 失败时返回 -1。
fork函数将创建调用的进程副本(概念上略难)。也就是说,并非根据完全不同的程序创建进程,而是**复制正在运行的、调用fork函数的进程。**另外,两个进程都将执行fork函数调用后的语句(准确的说是在fork函数返回后)。但因为通过同一个进程、复制相同的内存空间,之后的程序流要根据fork函数的返回值加以区分。即利用fork函数的如下特点区分程序执行流程。
父进程:fork函数返回子进程ID。——》原进程
子进程:fork函数返回0。——》复制的进程
此处 “父进程”(Parent Process)指原进程,即调用fork函数的主体,而 “子进程”(Child Process)是通过父进程调用fork函数复制出的进程。
fork函数调用后分成了两个完全不同的进程,只是二者共享同一代码而已。
进程的销毁
僵尸(Zombie)进程
程完成工作后(执行完main函数中的程序后)应被销毁。但有时这些进程将变成僵尸进程,占用系统中的重要资源。是给系统带来负担的原因之一。
产生僵尸进程的原因
调用fork函数产生子进程的终止方式:
传递参数并调用exit函数。
main函数中执行return语句并返回值。
僵尸进程何时被销毁呢? “应该向创建子进程的父进程传递子进程的exit参数值或return语句的返回值。”
只有父进程主动发起请求(函数调用)时,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。
==子进程需要父进程去收回,而不是操作系统。==如果父进程终止,处于僵尸状态的子进程将同时销毁。
销毁僵尸进程1: 利用 wait函数
父进程主动请求获取子进程的返回值
#include <sys/wait.h>
pid_t wait(int * statloc); //--> 成功时返回终止的子进程ID, 失败时返回 -1。
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存在该函数的参数所指的内存空间中。但函数参数所指的单元中还包含其他信息,因此需要通过下列宏进行分离:
if(WIFEXITED(status)) // 是正常终止的吗?
{
puts("Normal termination!");
printf("Child pass num: %d", WEXITSTATUS(status)); //那么返回值是多少?
}
调用wait函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此需谨慎调用该函数。
销毁僵尸进程2:使用 waitpid函数
wait函数会引起程序阻塞,还可以考虑 waitpid函数。
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* statloc, int options);
//--> 成功时返回终止的子进程ID(或0), 失败时返回 -1。
//pid 等待终止的目标进程的ID,若传递-1,则与 wait函数相同,可以等待任意子进程终止。
//statloc 与wait函数的 statloc参数具有相同含义。
//options 传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。
waitpid.c P174
”子进程何时结束?父进程不能一直等待“
向操作系统求助
子进程终止的识别主体是操作系统。
信号处理(Signal Handling)机制。此处的“信号”是在特定事件发生时由操作系统向进程发送的消息。 为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”。
信号和signal函数
**”注册信号“过程:**即进程发现自己的子进程结束时,请求操作系统调用特定函数。(父进程告诉操作系统,在自己子进程结束时,操作系统应该去干什么)
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int); //--> 为了在产生信号时调用,返回之前注册的函数指针。
// (int)表示函数参数为int型;
//第一个参数是指特殊情况信息;
//第二个参数为特殊情况下将要调用的函数的地址值(指针)。
//发生第一个参数代表的情况时,调用第二个参数所指的函数。
//第一个参数信息:
//SIGALRM: 已到通过调用alarm函数注册的时间。
//SIGINT: 输入 ctrl+C。
//SIGCHLD: 子进程终止。
signal(SIGCHLD, mychild); //子进程终止则调用mychild函数。
signal(SIGALRM, timeout); //已到通过alarm函数注册的时间,请调用timeout函数
signal(SIGINT, keycontol); //输入CTRL+C时调用keycontol函数
//以上就是信号注册过程。注册好信号后,发生注册信号时(注册的情况发生时),操作系统将调用该信号对应的函数。
#include<unistd.h>
unsigned int alarm(unsigned int seconds); //--> 返回 0或以秒为单位的距 SIGALRM信号发生所剩时间。
//如果调用该函数的同时向它传递一个正整数型参数,相应时间后(以秒为单位)将产生 SIGALRM信号。若向该函数传递0,则之前对SIGALRM信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用signal函数)终止进程,不做任何处理。
源码分析signal.c P178
发生信号时将唤醒由于调用sleep函数而进入阻塞状态进程。
利用 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); //信号处理函数的地址
sigset_t sa_mask; //0,用于指定信号相关的选项和特性
int sa_flags; 0,同上
}
//sigemptyset(&act.sa_mask); //将sa_mask成员的所有位初始化为0
利用信号处理技术消灭僵尸进程
源码分析remove_zombie.c P181
基于进程的并发服务器模型
第一阶段:回声服务器端(父进程)通过调用accept函数受理连接请求。
第二阶段:此时获取的套接字文件描述符创建 并传递给子进程。
第三阶段:子进程利用传递来的文件描述符提供服务。
因为子进程会复制父进程拥有的所有资源,实际根本不会另外经过传递文件描述符的过程。
实现并发服务器
源码分析echo_mpserv.c P184
通过fork函数复制文件描述符
**套接字不是归进程所有的,而是归操作系统所有,只是进程拥有代表相应套接字的文件描述符。**所以子进程并不复制套接字,只是复制文件描述符。
**1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。**所以在echo_mpserv.c中才会close两次。
客户端中分割I/O程序
分割 I/O程序的优点
原回声客户端:向服务器传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。
原因:传输数据后要等待服务器端返回的数据,因为程序代码中重复调用了 read 和 write 函数。只能这么写的原因之一是,程序在 1 个进程中运行,现在可以创建多个进程,因此可以分割数据收发过程。
客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。
另一个好处:可以提高频繁交换数据的程序性能
回声客户端的I/O程序分割
源码分析echo_mpclient.c P190
进程间通信,意味着两个不同的进程中可以交换数据。
对进程间通信的基本理解
进程具有完全独立的内存结构。就连通过fork函数创建的子进程也不会与父进程共享内存空间。因此,进程间通信只能通过其他特殊方法完成。
通过管道实现进程间通信
下图是基于**管道(PIPE)**的进程间通信的模型:
管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是 fork 函数的复制对象)。所以,两个进程通过操作系统提供的内存空间进行通信。
//创建管道
#include <unistd.h>
int pipe(int filedes[2]);
/*
成功时返回 0 ,失败时返回 -1
filedes[0]: 通过管道接收数据时使用的文件描述符,即管道出口
filedes[1]: 通过管道传输数据时使用的文件描述符,即管道入口
*/
父进程创建函数时将创建管道,同时获取对应于出入口的文件描述符,此时父进程可以读写同一管道。但父进程的目的是与子进程进行数据交换,因此需要将入口或出口中的 1 个文件描述符传递给子进程。调用fork函数后,子进程将同时拥有2个文件描述符。
可以看出,父子进程都可以访问管道的I/O路径,但子进程仅用输入路径,父进程仅用输出路径。
通过管道进行进程间双向通信
源码pipe2.c P196
运行结果是正确的,但是如果注释掉第18行的代码,就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。**也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。(自己读取自己写入的数据)**因为,注释第18行会产生问题。第19行,自己成将读回自己在第 17 行向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。
当一个管道不满足需求时,就需要创建两个管道,各自负责不同的数据流动。==源码pipe3.c P198==通过创建两个管道实现了功能,此时,不需要额外再使用 sleep 函数。
保存消息的回声服务器端
下面对第 10 章的 echo_mpserv.c进行改进,添加一个功能:
将回声客户端传输的字符串按序保存到文件中
源码echo_storeserv.c P199
共3个子进程,1个父进程;
3个子进程:1个用来写入文件,另外两个用于与客户端交换数据;
并发服务器的第二种实现方法——基于I/O复用的服务器端构建
多进程服务器端的缺点和解决方法
为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但并非十全十美,因为创建进程要付出很大的代价。这需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,所以相互间的数据交换也要采用相对复杂的方法。
I/O 复用技术可以解决这个问题。
理解复用
复用:
- 在 1 个通信频道中传递多个数据(信号)的技术
2.为了提高物理设备的效率,只用最少的物理要素传递最多数据时使用的技术
**时分复用技术:**不同时进行;
**频分复用技术:**可同时进行,但是存在“误差”
复用技术在服务器端的应用
对比可以看出,引入复用技术之后,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有一个。
select 函数的功能和调用顺序
使用 select 函数时可以将多个文件描述符集中到一起统一监视,项目如下:
是否存在套接字接收数据?
无需阻塞传输数据的套接字有哪些?
哪些套接字发生了异常?
监视项称为“事件(event)”;当发生监视项对应情况时,称「发生了事件」。
设置文件描述符
监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中在一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述 3 种监视项分成 3 类。
利用 fd_set 数组变量执行此操作,如图所示,该数组是存有0和1的位数组。
最左端的位表示文件描述符 0(所在位置)。如果该位设置为 1,则表示该文件描述符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是描述符 1 和 3。
//fd_set变量的操作是以位为单位进行的,这也意味着直接操作该变量会比较繁琐。 //fd_set变量中注册过更改值的操作都由下列宏完成。 FD_ZERO(fd_set* fdset); //将fd_set变量的所有位初始化为0。 FD_SET(int fd, fd_set* fdset); // 在参数fdset指向的变量中注册文件描述符fd的信息。 FD_CLR(int fd, fd_set* fdset); //从参数fdset指向的变量中清除文件描述符fd的信息。 FD_ISSET(int fd, fd_set* fdset); //若参数fdset指向的变量中包含文件描述符fd信息,则返回“真”。 int main(void) { fd_set set; // fd0 fd1 fd2 fd3 FD_ZERO(&set); // 0 0 0 0 ..... // fd0 fd1 fd2 fd3 FD_SET(1, &set); // 0 1 0 0 ..... // fd0 fd1 fd2 fd3 FD_SET(2, &set); // 0 1 1 0 ..... // fd0 fd1 fd2 fd3 FD_CLR(2, &set); // 0 1 0 0 ..... }
设置检查(监视)范围及超时
#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);
/*
成功时返回大于 0 的值,失败时返回 -1
maxfd: 监视对象文件描述符数量
readset: 将所有关注「是否存在待读取数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。
timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
返回值: 发生错误时返回 -1,超时时返回0。
因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
*/
第一,文件描述符的监视范围和 select 的第一个参数有关。实际上,select 函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在 fd_set 变量中的文件描述符数。但每次新建文件描述符时,其值就会增加 1 ,故只需将最大的文件描述符值加 1 再传递给 select 函数即可。加 1 是因为文件描述符的值是从 0 开始的。
第二,select 函数的超时时间与 select 函数的最后一个参数有关,其中 timeval 结构体定义如下:
struct timeval
{
long tv_sec; //seconds
long tv_usec; //microseconds
};
**本来 select 函数只有在监视文件描述符发生变化时才返回。**如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。此时,即使文件描述符未发生变化,**只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0 。**因此,可以通过返回值了解原因。如果不向设置超时,则传递 NULL 参数。
调用 select 函数查看结果
select函数的返回值,如果返回大于0的整数,说明相应数量的文件描述符发生变化。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ov4xorEQ-1631515893586)(https://s2.ax1x.com/2019/01/23/kA06dx.png)]
select 函数调用完成候,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化。
select 函数调用示例
源码select.c P211
实现I/O复用服务器端
源码 echo_selectserv.c P213
//客户端的连接请求同样通过传输数据完成。因此,服务器端套接字中有接受的数据,就意味着有新的连接请求
Linux 中的 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: 接收数据时指定的可选项参数 */
send 和 recv 函数都是最后一个参数是收发数据的可选项,该选项可以用位或(bit OR)运算符(| 运算符)同时传递多个信息。
可选项(Option) | 含义 | send | recv |
---|---|---|---|
MSG_OOB | 用于传输带外数据(Out-of-band data) | O | O |
MSG_PEEK | 验证输入缓冲中是否存在接受的数据 | X | O |
MSG_DONTROUTE | 数据传输过程中不参照本地路由(Routing)表,在本地(Local)网络中寻找目的地 | O | X |
MSG_DONTWAIT | 调用 I/O 函数时不阻塞,用于使用非阻塞(Non-blocking)I/O | O | O |
MSG_WAITALL | 防止函数返回,直到接收到全部请求的字节数 | X | O |
MSG_OOB:发送紧急消息
MSG_OOB 可选项用于创建特殊发送方法和通道以发送紧急消息。
源码分析oob_send.c P223;oob_recv.c P224
fcntl(recv_sock, F_SETOWN, getpid());
//文件描述符 recv_sock 指向的套接字引发的 SIGURG 信号处理进程变为 getpid 函数返回值用作 ID 进程.
//处理 SIGURG 信号时必须指定处理信号所用的进程,而 getpid 返回的是调用此函数的进程 ID
通过 MSG_OOB 可选项传递数据时只返回 1 个字节,而且也不快。
的确,通过 MSG_OOB 并不会加快传输速度,而通过信号处理函数 urg_handler 也只能读取一个字节。剩余数据只能通过未设置 MSG_OOB 可选项的普通输入函数读取。因为 TCP 不存在真正意义上的「带外数据」。实际上,MSG_OOB 中的 OOB 指的是 Out-of-band ,而「带外数据」的含义是:
通过去完全不同的通信路径传输的数据
即真正意义上的 Out-of-band 需要通过单独的通信路径高速传输数据,但是 TCP 不另外提供,只利用 TCP 的紧急模式(Urgent mode)进行传输。
紧急模式工作原理
MSG_OOB 的真正意义在于督促数据接收对象尽快处理数据。这是紧急模式的全部内容,而 TCP 「保持传输顺序」的传输特性依然成立。TCP 的紧急消息无法保证及时到达,但是可以要求急救。
MSG_OOB 可选项状态下的数据传输过程,如图:
如果缓冲最左端的位置视作偏移量 0 。字符 0 保存于偏移量 2 的位置。另外,字符 0 右侧偏移量为 3 的位置存有紧急指针(Urgent Pointer)。紧急指针指向紧急消息的下一个位置(偏移量加一),同时向对方主机传递一下信息:紧急指针指向的偏移量为 3 之前的部分就是紧急消息。
**实际上只用了一个字节表示紧急消息。**这一点可以通过图中用于传输数据的 TCP 数据包(段)的结构看得更清楚,如图:
TCP 数据包实际包含更多信息。TCP 头部包含如下两种信息:
URG=1:载有紧急消息的数据包
URG指针:紧急指针位于偏移量为 3 的位置。
指定 MSG_OOB 选项的数据包本身就是紧急数据包,并通过紧急指针表示紧急消息所在的位置。
紧急消息的意义在于督促消息处理,而非紧急传输形式受限的信息。
检查输入缓冲
同时设置 MSG_PEEK 选项和 MSG_DONTWAIT 选项,以验证输入缓冲是否存在接收的数据。**设置 MSG_PEEK 选项并调用 recv 函数时,即使读取了输入缓冲的数据也不会删除。**因此,该选项通常与 MSG_DONTWAIT 合作,用于调用以非阻塞方式验证待读数据存与否的函数。
源码peek_recv.c peek_send.c P229
结果验证,仅发送了一次的数据被读取了 2 次,因为第一次调用 recv 函数时设置了 MSG_PEEK 可选项。
使用 readv & writev 函数
readv & writev 函数的功能可概括如下:
对数据进行整合传输及发送的函数。
也就是说,通过 writev 函数可以将分散保存在多个缓冲中的数据一并发送,通过 readv 函数可以由多个缓冲分别接收。因此,适用这 2 个函数可以减少 I/O 函数的调用次数。
#include <sys/uio.h>
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
/*
成功时返回发送的字节数,失败时返回 -1
filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符.
iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息
iovcnt: 向第二个参数传递 数组长度
*/
struct iovec
{
void *iov_base; //缓冲地址
size_t iov_len; //缓冲大小
};
writev 的第一个参数是1,是文件描述符,因此向控制台输出数据,ptr 是存有待发送数据信息的 iovec 数组指针。第三个参数为 2,因此,从 ptr 指向的地址开始,共浏览 2 个 iovec 结构体变量,发送这些指针指向的缓冲数据。
#include <sys/uio.h> ssize_t readv(int filedes, const struct iovc *iov, int iovcnt); /* 成功时返回接收的字节数,失败时返回 -1 filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符. iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息 iovcnt: 向第二个参数传递数组长度 */ //源码readv.c #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; vec[0].iov_base = buf1; vec[0].iov_len = 5; vec[1].iov_base = buf2; vec[1].iov_len = BUF_SIZE; str_len = readv(0, vec, 2); printf("Read bytes: %d \n", str_len); printf("First message: %s \n", buf1); printf("Second message: %s \n", buf2); return 0; } /* 26、27行:设置第一个数据的保存位置和大小。接收数据的大小已指定为5,因此无论buf1的大小是多少,最多只能保存5个字节 14、15行:vec[0]中注册的缓冲中保存5个字节,剩余数据将保存到vec[1]中注册的缓冲。结构体iovec的成员中ioe_len中应写入接收的最大字节数 17行:readv函数的第一参数是0,因此从标准输入接受数据。 */
合理使用 readv & writev 函数
实际上,能使用该函数的所有情况都适用。
其意义在于减少数据包个数。假设为了提高效率在服务器端明确禁用了 Nagle 算法。其实 writev 函数在不采用 Nagle 算法(大文件)时更有价值,如图:
使用writev函数将所有数据一次性写入输出缓冲,则很有可能仅通过1个数据包传输数据。
多播(Multicast)方式的数据传输是基于 UDP 完成的。
区别在于,UDP 数据传输以单一目标进行,而多播数据同时传递到加入(注册)特定组的大量主机。换言之,采用多播方式时,可以同时向多个主机传递数据。
多播的数据传输方式以及流量方面的优点
多播的数据传输特点可整理如下:
多播组是 D 类IP地址(224.0.0.0~239.255.255.255)(11100000 ~11101111)。
多播是基于 UDP 完成的,也就是说,多播数据包的格式与 UDP 数据包相同。只是与一般的 UDP 数据包不同。向网络传递 1 个多播数据包时,路由器将复制该数据包并传递到多个主机。像这样,**多播需要借助路由器完成。**如图所示:
若通过 TCP 或 UDP 向 1000 个主机发送文件,则共需要传递 1000 次。但是此时如果用多播网络传输文件,则只需要发送一次。这时由 1000 台主机构成的网络中的路由器负责复制文件并传递到主机。就因为这种特性,多播主要用于「多媒体数据实时传输」。
另外,理论上可以完成多播通信,但是不少路由器并不支持多播,或即便支持也因网络拥堵问题故意阻断多播。因此,为了在不支持多播的路由器中完成多播通信,也会使用隧道(Tunneling)技术。
路由(Routing)和 TTL(Time to Live,生存时间),以及加入组的办法
为了传递多播数据包,必须设置 TTL 。TTL 是 Time to Live的简写,是决定**「数据包传递距离」**的主要因素。**TTL 用整数表示,并且每经过一个路由器就减一。TTL 变为 0 时,该数据包就无法再被传递,只能销毁。**因此,TTL 的值设置过大将影响网络流量。当然,设置过小,也无法传递到目标。
接下来是 TTL 的设置方法。TTL 是可以通过第九章的套接字可选项完成的。与设置 TTL 相关的协议层为 IPPROTO_IP ,选项名为IP_MULTICAST_TTL。因此,可以用如下代码把 TTL 设置为 64
int send_sock;
int time_live = 64;
...
send_sock=socket(PF_INET,SOCK_DGRAM,0);
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,0); ... join_adr.imr_multiaddr.s_addr="多播组地址信息"; join_adr.imr_interface.s_addr="加入多播组的主机地址信息"; setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)&join_adr,sizeof(time_live); ... //ip_mreq结构体 struct ip_mreq { struct in_addr imr_multiaddr; //写入加入组的IP地址 struct in_addr imr_interface; //加入该组的套接字所属主机的IP地址 };
实现多播 Sender 和 Receiver
多播中用「发送者」(以下称为 Sender) 和「接收者」(以下称为 Receiver)替代服务器端和客户端。顾名思义,此处的 Sender 是多播数据的发送主体,Receiver 是需要多播组加入过程的数据接收主体。下面是示例,示例的运行场景如下:
源码news_sender.c news_receiver.c P243
广播(Broadcast)在「一次性向多个主机发送数据」这一点上与多播类似,但传输数据的范围有区别。多播即使在跨越不同网络的情况下,只要加入多播组就能接受数据。相反,广播只能向同一网络中的主机传输数据。
广播的理解和实现方法
广播是向同一网络中的所有主机传输数据的方法。与多播相同,广播也是通过 UDP 来完成的。根据传输数据时使用的IP地址形式,广播分为以下两种:
二者在实现上的差别主要在于IP地址。**直接广播的IP地址中除了网络地址外,其余主机地址全部设置成 1。**例如,希望向网络地址 192.12.34 中的所有主机传输数据时,可以向 192.12.34.255 传输。换言之,可以采取直接广播的方式向特定区域内所有主机传输数据。
反之,本地广播中使用的IP地址限定为 255.255.255.255 。例如,192.32.24 网络中的主机向 255.255.255.255 传输数据时,数据将传输到 192.32.24 网络中所有主机。
数据通信中使用的IP地址是与 UDP 示例的唯一区别。默认生成的套接字会阻止广播,因此,只需通过如下代码更改默认设置。
int send_sock;
int bcast=1; //对变量进行初始化以将SO_BROADCAST选项信息改为1。
...
send_sock=socket(PF_INET,SOCK_DGRAM,0);
...
setsockopt(send_sock,SOL_SOCKET,SO_BROADCAST,(void*)&bcast,sizeof(bcast));
...
实现广播数据的 Sender 和 Receiver
源码news_sender_brd.c news_recevier_brd.c P248
标准 I/O 函数的两个优点
标准 I/O 函数的两个优点:
创建套接字时,操作系统会准备 I/O 缓冲。此缓冲在执行 TCP 协议时发挥着非常重要的作用。**此时若使用标准 I/O 函数,将得到额外的缓冲支持。**如下图:
**套接字缓冲主要是为了实现TCP协议而设立的。**例如TCP传输中丢失数据时再次重传时,再次发送的数据存在套接字的输出缓冲中。
**使用标准I/O函数缓冲的主要目的是为了提高性能。**从以下两点可以说明性能的提高:
比较 1 个字节的数据发送 10 次(10个数据包)的情况和 10 个字节发送 1 次的情况。发送数据时,数据包中含有头信息。头信与数据大小无关,是按照一定的格式填入的。假设头信息占 40 个字节,需要传输的数据量也存在较大区别:
标准 I/O 函数和系统函数之间的性能对比
源码——系统函数的示例:syscpy.c P258
源码——标准 I/O 函数复制文件:stdcpy.c
标准 I/O 函数的几个缺点
标准 I/O 函数存在以下几个缺点:
创建套接字时返回文件描述符,为了使用标准I/O函数,只能将其转换为FILE结构体指针。
利用 fdopen 函数转换为 FILE 结构体指针
#include <stdio.h>
FILE *fdopen(int fildes, const char *mode);
/*
成功时返回转换的 FILE 结构体指针,失败时返回 NULL
fildes : 需要转换的文件描述符
mode : 将要创建的 FILE 结构体指针的模式信息,与fopen函数中的打开模式相同。常用读模式“r”和写模式“w”
*/
#include <stdio.h> #include <fcntl.h> int main() { FILE *fp; int fd = open("data.dat", O_WRONLY | O_CREAT | O_TRUNC); //创建文件并返回文件描述符 if (fd == -1) { fputs("file open error", stdout); return -1; } fd = fdopen(fd, "w"); //返回 写 模式的 FILE 指针 fputs("NetWork C programming \n", fp); fclose(fp); return 0; } //运行命令 gcc desto.c -o desto ./desto cat data.dat //cat命令用于连接文件并打印到标准输出设备上。
利用 fileno 函数转换为文件描述符
与fdopen函数功能相反。
#include <stdio.h>
int fileno(FILE *stream);
/*
成功时返回文件描述符,失败时返回 -1
*stream 并没有改变
*/
把第四章的回声客户端和回声服务端的内容改为基于标准 I/O 函数的数据交换形式。
源码echo_stdserv.c echo_client.c P262
“流”理解为数据收发路径。
2次I/O流分离
之前有两种分离方法:
分离「流」的好处
首先是第 10 章「流」的分离目的:
下面是第 15 章「流」分离的目的:
「流」分离带来的 EOF 问题
第 7 章介绍过 EOF 的传递方法和半关闭的必要性。有一个语句:
shutdown(sock,SHUT_WR);
调用shutdown函数的基于半关闭的EOF传递方法。
**但是还没有讲采用 fdopen 函数怎么半关闭。那么是否是通过 fclose 函数关闭流呢?经过源码sep_serv.c与sep_clnt.c P266**后可看出:
服务端代码的 fclose(writefp);
这一句,完全关闭了套接字而不是半关闭。这才是这一章需要解决的问题。
终止「流」时无法半关闭原因
服务端代码中的两个FILE 指针、文件描述符和套接字中的关系:
从图中可以看到,两个指针都是基于同一文件描述符创建的。因此,针对于任何一个 FILE 指针调用 fclose 函数都会关闭文件描述符,如图所示:
从图中看到,销毁套接字时再也无法进行数据交换。那如何进入可以进入但是无法输出的半关闭状态呢?如下图所示:
只需要创建 FILE 指针前先复制文件描述符即可。复制后另外创建一个文件描述符,然后利用各自的文件描述符生成读模式的 FILE 指针和写模式的 FILE 指针。这就为半关闭创造好了环境,因为套接字和文件描述符具有如下关系:
销毁所有文件描述符候才能销毁套接字.
也就是说,针对写模式 FILE 指针调用 fclose 函数时,只能销毁与该 FILE 指针相关的文件描述符,无法销毁套接字,如下图:
调用 fclose 函数候还剩下 1 个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是!只是准备好了进入半关闭状态,而不是已经进入了半关闭状态。仔细观察,还剩下一个文件描述符。而该文件描述符可以同时进行 I/O 。因此,不但没有发送 EOF ,而且仍然可以利用文件描述符进行输出。
只有发送EOF,关闭发送或读取两条路的其中之一,才称得上是半关闭状态。
复制文件描述符
与调用 fork 函数不同,调用 fork 函数将复制整个进程,此处讨论的是同一进程内完成对完成描述符的复制。
**文件描述符的值不能重复。**复制完成后,两个文件描述符都可以访问同一个文件,但是编号不同。
“为了访问同一文件或套接字,创建另一个文件描述符。”
dup 和 dup2
#include <unistd.h>
int dup(int fildes);
int dup2(int fildes, int fildes2);
/*
成功时返回复制的文件描述符,失败时返回 -1
fildes : 需要复制的文件描述符
fildes2 : 明确指定的文件描述符的整数值。
*/
dup2 函数明确指定复制的文件描述符的整数值。向其传递大于 0 且小于进程能生成的最大文件描述符值时,该值将成为复制出的文件描述符值。
复制文件描述符后「流」的分离
源码sep_serv2.c P273
调用shutdown函数,服务器端进入半关闭状态,并向客户端发送EOF。
无论复制多少文件描述符,均应调用shutdown函数并发送EOF并进入半关闭状态。
基于 select 的 I/O 复用技术速度慢的原因
第 12 章实现了基于 select 的 I/O 复用技术服务端,其中有不合理的设计如下:
调用 select 函数后,并不是把发生变化的文件描述符单独集中在一起,而是通过作为监视对象的 fd_set 变量的变化,找出发生变化的文件描述符(54,56行),因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 会发生变化,所以调用 select 函数前应该复制并保存原有信息,并在每次调用 select 函数时传递新的监视对象信息。
select 性能上最大的弱点是:每次传递监视对象信息,准确的说,select 是监视套接字变化的函数。而套接字是操作系统管理的,所以 select 函数要借助操作系统才能完成功能。select 函数的这一缺点可以通过如下方式弥补:
仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项。
Linux 的支持方式是 epoll ,Windows 的支持方式是 IOCP。
select 也有优点
select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,使用 select 函数满足以下两个条件:
实现 epoll 时必要的函数和结构体
能够克服 select 函数缺点的 epoll 函数具有以下优点,这些优点正好与之前的 select 函数缺点相反:
下面是 epoll 函数的功能:
select 函数中为了保存监视对象的文件描述符,直接声明了 fd_set 变量,但 epoll 方式下的操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时用的函数就是 epoll_create 。
此外,为了添加和删除监视对象文件描述符,select 方式中需要 FD_SET、FD_CLR 函数。但在 epoll 方式中,通过 epoll_ctl 函数请求操作系统完成。最后,select 方式下调用 select 函数等待文件描述符的变化,而 epoll_wait 调用 epoll_wait 函数。还有,select 方式中通过 fd_set 变量查看监视对象的状态变化,而 epoll 方式通过如下结构体 epoll_event 将发生变化的文件描述符单独集中在一起。
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 结构体数组候,**传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入数组。**因此,无需像 select 函数那样针对所有文件描述符进行循环。
epoll_create
epoll 是从 Linux 的 2.5.44 版内核开始引入的。通过以下命令可以查看 Linux 内核版本:
cat /proc/sys/kernel/osrelease
#include <sys/epoll.h>
int epoll_create(int size);
/*
成功时返回 epoll 例程的文件描述符,失败时返回 -1
size:epoll 实例的大小
*/
调用 epoll_create 函数时创建的文件描述符保存空间称为**「epoll 例程」**,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提出的建议。换言之,size 并不用来决定 epoll 的大小,而仅供操作系统参考。
epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符,也就是说返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其他文件描述符相同,也要调用 close 函数。
epoll_ctl
生成例程后,应在其内部注册监视对象文件描述符,此时使用 epoll_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:监视对象的事件类型
*/
epoll_ctl(A,EPOLL_CTL_ADD,B,C);
//epoll 例程 A 中注册文件描述符 B ,主要目的是为了监视参数 C 中的事件
epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);
//从 epoll 例程 A 中删除文件描述符 B
下面是第二个参数的含义:
第四个参数类型是epoll_event结构体指针:
epoll_event 结构体用于保存事件的文件描述符结合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。该函数中 epoll_event 结构体的定义并不显眼,因此通过调用语句说明该结构体在 epoll_ctl 函数中的应用。
struct epoll_event event;
...
event.events=EPOLLIN;//发生需要读取数据的情况时
event.data.fd=sockfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event);
...
//将 epfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。
epoll_event 的成员 events 中可以保存的常量及所指的事件类型:
可通过位运算同事传递多个上述参数。
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);
...
调用函数后,返回发生事件的文件描述符数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样插入针对所有文件描述符的循环。
基于 epoll 的回声服务器端
源码 echo_epollserv.c P281
条件触发(Level Trigger)和边缘触发(Edge Trigger)
条件触发和边缘触发的区别在于发生事件的时间点
条件触发方式中,只要输入缓冲有数据就会一直通知该事件
例如,服务器端输入缓冲收到 50 字节数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但是服务器端读取 20 字节后还剩下 30 字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。
边缘触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再进行注册。
掌握条件触发的事件特性
epoll 默认以条件触发的方式工作
源码echo_EPLTserv.c P284
select模型是以条件触发的方式工作的。
边缘触发的服务器端必知的两点
Linux 套接字相关函数一般通过 -1 通知发生了错误。虽然知道发生了错误,但仅凭这些内容无法得知产生错误的原因。因此,为了在发生错误的时候提额外的信息,Linux 声明了如下全局变量:
int errno;
为了访问该变量,需要引入 error.h
头文件,因此此头文件有上述变量的 extren 声明。另外,每种函数发生错误时,保存在 errno 变量中的值都不同。
Linux 中提供的改变和更改文件属性的办法:
#include <fcntl.h>
int fcntl(int fields, 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 确认错误的原因是:边缘触发方式中,接收数据仅注册一次该事件。
因为这种特点,一旦发生输入相关事件时,就应该读取输入缓冲中的全部数据。因此需要验证输入缓冲是否为空。
read 函数发现输入缓冲中没有数据可读时返回 -1,同时在 errno 中保存 EAGAIN 常量。
边缘触发条件下,以阻塞方式工作的 read & write 函数有可能引起服务端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞 read & write 函数。
源码 echo_EPETserv.c P289
条件触发和边缘触发孰优孰劣
边缘触发方式可以分离接收数据和处理数据的时间点!
运行流程如下:
为了完成这个过程,如果可以按照如下流程运行,服务端的实现并不难:
但是实际情况中可能是下面这样:
因此,即使输入缓冲收到数据,服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来很大灵活性。
引入线程背景
多进程模型的缺点:
但是更大的缺点是下面的:
只有一个 CPU 的系统是将时间分成多个微小的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」的过程。「上下文切换」是指运行程序前需要将相应进程信息读入内存,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息。这就是上下文切换。但是此时进程 A 的数据将被移动到硬盘,所以上下文切换要很长时间,即使通过优化加快速度,也会存在一定的局限。
为了将进程的各种劣势降至最低程度(不是直接消除)而设立的一种「轻量级进程」。线程比进程具有如下优点:
线程和进程的差异
线程是为了解决:为了得到多条代码执行流而复制整个内存区域的负担太重。
每个进程的内存空间都由保存全局变量的「数据区」、向 malloc 等函数动态分配提供空间的堆(Heap)、**函数运行时间使用的栈(Stack)**构成。每个进程都有独立的这种空间,多个进程的内存结构如图所示:
但如果以获得多个代码执行流为目的,则不应该像上图那样完全分离内存结构,而只需分离栈区域。通过这种方式可以获得如下优势:
这就是线程。
如图所示,多个线程共享数据区和堆。为了保持这种结构,线程将在进程内创建并运行。也就是说,进程和线程可以定义为如下形式:
如果说进程在操作系统内部生成多个执行流,那么线程就在同一进程内部创建多条执行流。因此,操作系统、进程、线程之间的关系可以表示为下图:
可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)是IEEE为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称。
线程创建方法也是以POSIX标准为依据的。
线程的创建和执行流程
线程具有单独的执行流,因此需要单独定义线程的 main 函数,还需要请求操作系统在单独的执行流中执行该函数,完成函数功能的函数如下:
#include <pthread.h>
int pthread_create(pthread_t *restrict thread,
const pthread_attr_t *restrict attr,
void *(*start_routine)(void *),
void *restrict arg);
/*
成功时返回 0 ,失败时返回 -1
thread : 保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID
attr : 用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程
start_routine : 相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针)
arg : 通过第三个参数传递的调用函数时包含传递参数信息的变量地址值
*/
restrict,C语言中的一种类型限定符(Type Qualifiers),用于告诉编译器,对象已经被指针所引用,不能通过除该指针外所有其他直接或间接的方式修改该对象的内容。
#include <stdio.h> #include <pthread.h> #include <unistd.h> void *thread_main(void *arg); int main(int argc, char *argv[]) { pthread_t t_id; int thread_param = 5; // 请求创建一个线程,从 thread_main 调用开始,在单独的执行流中运行。同时传递参数 if (pthread_create(&t_id, NULL, thread_main, (void *)&thread_param) != 0) { puts("pthread_create() error"); return -1; } sleep(10); //延迟进程终止时间 puts("end of main"); return 0; } void *thread_main(void *arg) //传入的参数是 pthread_create 的第四个 { int i; int cnt = *((int *)arg); for (int i = 0; i < cnt; i++) { sleep(1); puts("running thread"); } return NULL; }
线程相关代码在编译时需要添加 -lpthread 选项声明连接线程库,这样才能调用头文件 pthread.h 中声明的函数。
线程执行过程:
若将16行的sleep语句换成 sleep(2);
将不会输出5次 “running thread”字符串。因为main函数返回后整个进程将被销毁,线程也会一同终止。
“线程相关程序中必须适当调用sleep函数” ———>并非如此!!!
通过下面的函数控制线程的执行流:
#include <pthread.h>
int pthread_join(pthread_t thread, void **status);
/*
成功时返回 0 ,失败时返回 -1
thread : 该参数值 ID 的线程终止后才会从该函数返回
status : 保存线程的 main 函数返回值的指针的变量地址值
*/
调用该函数的进程(或线程)将进入等待状态,直到第一个参数为 ID 的线程终止为止。而且可以得到线程的 main 函数的返回值。
执行流程:
可在临界区内调用的函数
多个线程同时调用同一函数时的问题(临界区)。
根据临界区是否引起问题,函数可以分为以下 2 类:
**线程安全的函数中同样可能存在临界区。**只是在线程安全的函数中,同时被多个线程调用时可通过一些措施避免问题。
**大多数标准函数都是线程安全函数。**操作系统在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数。比如,第 8 章的:
struct hostent *gethostbyname(const char *hostname);
//同一功能的安全函数
struct hostent *gethostbyname_r(const char *name,
struct hostent *result,
char *buffer,
int intbuflen,
int *h_errnop);
线程安全函数结尾通常是 _r
。但是使用线程安全函数会给程序员带来额外的负担,可以通过以下方法自动将 gethostbyname 函数调用改为 gethostbyname_r 函数调用。
声明头文件前定义
_REENTRANT
宏。
无需特意更改源代码加,可以在编译的时候指定编译参数定义宏。
gcc -D_REENTRANT mythread.c -o mthread -lpthread
工作(Worker)线程模型
下面的示例是计算从 1 到 10 的和,但并不是通过 main 函数进行运算,而是创建两个线程,其中一个线程计算 1 到 5 的和,另一个线程计算 6 到 10 的和,main 函数只负责输出运算结果。这种方式的编程模型称为「工作线程Worker threads 模型」,线程是main线程管理的工作。显示该程序的执行流程图:
//thread3.c #include <stdio.h> #include <pthread.h> void *thread_summation(void *arg); int sum = 0; int main(int argc, char *argv[]) { pthread_t id_t1, id_t2; int range1[] = {1, 5}; int range2[] = {6, 10}; pthread_create(&id_t1, NULL, thread_summation, (void *)range1); pthread_create(&id_t2, NULL, thread_summation, (void *)range2); pthread_join(id_t1, NULL); pthread_join(id_t2, NULL); printf("result: %d \n", sum); return 0; } void *thread_summation(void *arg) { int start = ((int *)arg)[0]; int end = ((int *)arg)[1]; while (start <= end) { sum += start; start++; } return NULL; }
上述代码中,“2个线程直接访问全局变量sum”
但此处存在临界区相关问题!!
源码 thread4.c P304
每次运行答案并不一样!
分析thread4.c 产生的问题原因 并解决
多个线程访问同一变量是问题
"2个线程直接访问全局变量sum
此处的“访问”是指变量值的更改
任何内存空间——只要被同时访问——都有可能发生问题
**问题:**A线程对全局变量sum的更改还未写回到内存中,此时B线程便获取sum的值(此时的值是还未更改的旧值),B线程更改sum后写回内存,A再写回内存。此过程便存在问题。
因此,A线程访问变量sum时应该阻止其他线程访问。
这就是“同步”。
临界区位置
临界区:“函数内同时运行多个线程时引发问题的多条语句构成的代码块”
临界区通常位于由线程运行的函数内部。
//thread4.c
void *thread_inc(void *arg)
{
int i;
for (i = 0; i < 50000000; i++)
num += 1;//临界区
return NULL;
}
void *thread_des(void *arg)
{
int i;
for (i = 0; i < 50000000; i++)
num -= 1;//临界区
return NULL;
}
由上述代码可知,临界区并非 num 本身,而是访问 num 的两条语句,这两条语句可能由多个线程同时运行,也是引起这个问题的直接原因。产生问题的原因可以分为以下三种情况:
比如发生以下情况:
线程 1 执行 thread_inc 的 num+=1 语句的同时,线程 2 执行 thread_des 函数的 num-=1 语句
也就是说,两条不同的语句由不同的线程执行时,也有可能构成临界区。前提是这 2 条语句访问同一内存空间。
解决上述问题,方法——线程同步
同步的两面性
**线程同步用于解决线程访问顺序引发的问题。**需要同步的情况可以从如下两方面考虑。
线程 A 负责向指定的内存空间内写入数据,线程 B 负责取走该数据。这种情况下,线程A应先访问约定的内存空间并保存数据,若B先访问并取走数据,便会产生错误。 所以这是有顺序的,不按照顺序就可能发生问题。所以这种也需要进行同步。
互斥量
互斥量(Mutual exclusion,缩写 Mutex)表示不允许多个线程同时访问。
//互斥量的创建及销毁函数
#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
*/
如果不需要配置特殊的互斥量属性,则向第二个参数传递 NULL 时,可以利用 PTHREAD_MUTEX_INITIALIZER 进行如下声明:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//推荐尽可能的使用 pthread_mutex_init 函数进行初始化,因为通过宏进行初始化时很难发现发生的错误。
//互斥量锁住或释放临界区
#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 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。
pthread_mutex_lock(&mutex);
//临界区开始
//...
//临界区结束
pthread_mutex_unlock(&mutex);
线程退出临界区时,如果忘了调用 pthread_mutex_unlock 函数,那么其他为了进入临界区而调用 pthread_mutex_lock 的函数无法摆脱阻塞状态。这种情况称为**「死锁」**。
源码 mutex.c P312
最大限度减少互斥量 lock unlock 函数的调用次数
信号量
信号量是共享资源的数量。
“二进制信号量”(只用0和1)完成 “控制信号线程顺序”为中心的同步方法。
//信号量的创建及销毁 #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的函数 int sem_post(sem_t *sem); int sem_wait(sem_t *sem); /* 成功时返回 0 ,失败时返回其他值 sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时信号量减一 */
调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录这「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1 ,调用 wait_wait 函数时减一。但信号量的值不能小于 0 ,因此,**在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态**(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1 ,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1)。
sem_wait(&sem);//信号量变为0...
// 临界区的开始
//...
//临界区的结束
sem_post(&sem);//信号量变为1...
调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。
源码semaphore.c P315
销毁线程的 3 种方法
Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。
之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通过如下函数调用引导线程销毁。
#include <pthread.h>
int pthread_detach(pthread_t th);
/*
成功时返回 0 ,失败时返回其他值
thread : 终止的同时需要销毁的线程 ID
*/
调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。
多线程并发服务器端的实现
源码chat_server.c P317
源码chat_clnt.c P320
HTTP(HyperText Transfer Protocol,超文本传输协议)服务器端,即 Web 服务器端。
理解 Web 服务器端
“web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客户端的服务器端。”
Hypertext(超文本)是可以根据客户端请求而跳转的结构化信息。
HTTP协议是以超文本传输为目的而设计的应用层协议,属于基于TCP/IP实现的协议。
HTTP
从上图可以看出,服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称无状态的 Stateless 协议
为了弥补HTTP无法保持连接的缺点,Web编程中通常使用Cookie和Session技术。
请求消息(Request Message)的结构
客户端向服务端发起请求消息的结构:
从图中可以看出,请求消息可以分为请求行、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。其中“GET/index.html HTTP/1.1” 具有如下含义:
请求(GET)index.html 文件,通常以 1.1 版本的 HTTP 协议进行通信。
请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。
请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。
消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。
另外,消息体和消息头与之间以空行隔开,因此不会发生边界问题。
响应消息(Response Message)的结构
Web 服务器端向客户端传递的响应信息的结构。该响应消息由状态行、头信息、消息体等 3 个部分组成。
第一个字符串状态行中含有关于客户端请求的处理结果。例如,客户端请求 index.html 文件时,表示 index.html 文件是否存在、服务端是否发生问题而无法响应等不同情况的信息写入状态行。图中的「HTTP/1.1 200 OK」具有如下含义:
“我想用HTTP1.1版本进行响应,你的请求已正确处理(200 OK)”
表示“客户端请求的执行结果”的数字称为 状态码,典型的有一下几种:
**消息头中含有传输的数据类型和长度等信息。**图中的消息头含有如下信息:
服务端名为 SimpleWebServer ,传输的数据类型为 text/html(html格式的文本数据)。数据长度不超过 2048 个字节。
插入一个空行后,通过消息体发送客户端请求的文件数据。
实现基于 Linux 的多线程 Web 服务器端
源码webserv_linux.c P408
// 临界区的开始
//…
//临界区的结束
sem_post(&sem);//信号量变为1…
调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。
**==源码semaphore.c P315==**
销毁线程的 3 种方法
Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。
之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通过如下函数调用引导线程销毁。
#include <pthread.h>
int pthread_detach(pthread_t th);
/*
成功时返回 0 ,失败时返回其他值
thread : 终止的同时需要销毁的线程 ID
*/
调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。
多线程并发服务器端的实现
源码chat_server.c P317
源码chat_clnt.c P320
HTTP(HyperText Transfer Protocol,超文本传输协议)服务器端,即 Web 服务器端。
理解 Web 服务器端
“web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客户端的服务器端。”
Hypertext(超文本)是可以根据客户端请求而跳转的结构化信息。
HTTP协议是以超文本传输为目的而设计的应用层协议,属于基于TCP/IP实现的协议。
HTTP
从上图可以看出,服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称无状态的 Stateless 协议
为了弥补HTTP无法保持连接的缺点,Web编程中通常使用Cookie和Session技术。
请求消息(Request Message)的结构
客户端向服务端发起请求消息的结构:
从图中可以看出,请求消息可以分为请求行、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。其中“GET/index.html HTTP/1.1” 具有如下含义:
请求(GET)index.html 文件,通常以 1.1 版本的 HTTP 协议进行通信。
请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。
请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。
消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。
另外,消息体和消息头与之间以空行隔开,因此不会发生边界问题。
响应消息(Response Message)的结构
Web 服务器端向客户端传递的响应信息的结构。该响应消息由状态行、头信息、消息体等 3 个部分组成。
第一个字符串状态行中含有关于客户端请求的处理结果。例如,客户端请求 index.html 文件时,表示 index.html 文件是否存在、服务端是否发生问题而无法响应等不同情况的信息写入状态行。图中的「HTTP/1.1 200 OK」具有如下含义:
“我想用HTTP1.1版本进行响应,你的请求已正确处理(200 OK)”
表示“客户端请求的执行结果”的数字称为 状态码,典型的有一下几种:
**消息头中含有传输的数据类型和长度等信息。**图中的消息头含有如下信息:
服务端名为 SimpleWebServer ,传输的数据类型为 text/html(html格式的文本数据)。数据长度不超过 2048 个字节。
插入一个空行后,通过消息体发送客户端请求的文件数据。
实现基于 Linux 的多线程 Web 服务器端
源码webserv_linux.c P408
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。