目录
一、服务器模型
1. C/S 模型
2. P2P模型
二、服务器编程框架
1. I/O处理单元
2. 逻辑单元
3. 网络存储单元
4. 请求队列
三、网络编程基础API
1. socket 地址处理 API
(1)主机字节序和网络字节序
(2)通用socket地址
① sa_family成员
② sa_data成员
(3)专用socket地址
(4)IP地址转换函数
① inet_addr函数
② inet_aton函数
③ inet_ntoa函数
④ inet_pton函数
⑤ inet_ntop函数
2. 创建socket
① domain参数
② type参数
③ protocol参数
3. 命名socket
4. 监听socket
① sockfd参数
② backlog参数
5. 接受连接
① sockfd参数
② addr参数用来
6. 发起连接
① sockfd参数
② serv_addr参数
③ addrlen参数
7. 关闭连接
① sockfd参数
② howto参数
8. 数据读写(TCP)
① recv
② send
9. 地址信息函数
① getsockname
② getpeername
10. 网络信息API
① name参数
② addr参数
③ len参数
④ type参数
四、高效的事件处理模式
1. Reactor模式
2. Proactor模式
五、I/O复用
1. select系统调用
① nfds参数
② readfds、writefds和exceptfds参数
③ timeout参数
2. poll系统调用
① fds参数
② nfds参数
③ timeout参数
3. epoll系列系统调用
① fd参数
② op参数
③ event参数
一、服务器模型
1. C/S 模型
采用C/S模型 的 TCP服务器 和TCP客户端的工作流程:
① 服务器启动 后,首先 创建一个(或多个)监听 socket,并调用 bind 函数 将其绑定 到服务器 感兴趣的 端口上,然后调用 listen 函数 等待客户连接。
I/O模型有多种,图中 服务器使用的 是 I/O 复用 技术之一的 select 系统调用。
图中服务器 给客户端分配的 逻辑单元是 由 fork 系统调用 创建的 子进程。
需要注意的是,服务器在 处理一个 客户请求的 同时还会 继续监听 其他客户请求,否则就 变成了 效率低下的 串行服务器了、图中 服务器 同时监听 多个客户请求 是 通过 select 系统 调用 实现的。
2. P2P模型
从编程角度来讲,P2P 模型 可以看作 C/S 模型的扩展:每台主 机既是 客户端,又是 服务器。
二、服务器编程框架
服务器程序 种类繁多,但其 基本框架都一样,不同 之处在于 逻辑处理。
1. I/O处理单元
I/O 处理单元 是服务器 管理客户连接的 模块。它 通常要完成 以下工作:等待 并接受 新的客户连接,接收 客户数据,将服务器 响应数据返回 给客户端。
但是,数据的收发 不一定在 I/O 处理单元 中执行,也 可能在 逻辑单元中 执行,具体 在何处执行 取决于事件 处理模式。对于 一个服务器机群 来说,I/O 处理单元 是一个 专门的 接入 服务器。它实现负载均衡,从 所有逻辑服务器中 选取负荷最小的 一台来为 新客户服务。
2. 逻辑单元
对服务器机群 而言,一个逻辑单元本身 就是一台 逻辑服务器。服务器 通常拥有 多个逻辑单元,以 实现对多个 客户任务的 并行处理。
3. 网络存储单元
4. 请求队列
请求队列 是各单元之间的 通信方式的 抽象。I/O 处理单元 接收到 客户请求时,需要 以某种方式 通知一个 逻辑单元来 处理该请求。同样,多个逻辑单元 同时访问一个 存储单元时,也 需要采用 某种机制来协调 处理竞态条件。
对于服务器机群 而言,请求队列 是各台服务器之间 预先 建立的、静态的、永久 的 TCP 连接。这种 TCP 连接 能提高服务器之间 交换数据的 效率,因为 它避免了 动态建立 TCP 连接导致的 额外的 系统开销。
三、网络编程基础API
1. socket 地址处理 API
(1)主机字节序和网络字节序
字节序问题:现代CPU的累加器 一次都能 装载(至少)4字节(32位机),即一个整数。那么这 4 字节 在内存中排列的 顺序 将影响它被 累加器装载成的 整数的值。
字节序分为 大端字节序(big endian)和小端字节序(little endian)。
大端字节序 是 指一个整数的高位字节(23~31 bit)存储在 内存的 低地址处,低位字节(0~7bit)存储在 内存的 高地址处。
小端字节序 则是 指整数的 高位字节存储 在内存的 高地址处,而 低位字节则存储 在内存的 低地址处。
因此 大端字节序 也称为 网络字节序。现代 PC 大多采用 小端字节序,因此 小端字节序又被称为 主机字节序。
#include<netinet/in.h>
unsigned long int htonl(unsigned long int hostlong); // “host to network long”,即将长整型(32 bit)的主机字节序数据转化为网络字节序数据。
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
这 4 个函数中,长整型函数 通常用来转换 IP 地址,短整型 函数用 来 转换端口号(任何格式化的 数据 通过网络传输时,都应该使用这些 函数来 转换字节序)。
(2)通用socket地址
#include<bits/socket.h>
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
}
① sa_family成员
地址族类型(sa_family_t)的变量。地址族类型 通常 与协议族类型 对应。宏 PF_* 和 AF_* 都定义在 bits/socket.h 头文件中,且后者 与前者有 完全相同的值,所以 二者通常混用。
② sa_data成员
用于存放 socket 地址值。但是,不同的协议族的 地址值 具有不同的 含义 和 长度。
(3)专用socket地址
#include<sys/un.h>
struct sockaddr_un
{
sa_family_t sin_family; /*地址族:AF_UNIX*/
char sun_path[108]; /*文件路径名*/
};
TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用 socket 地址 结构体,它们分别用于IPv4 和 IPv6。
struct sockaddr_in {
sa_family_t sin_family; /* 地址族:AF_INET */
u_int16_t sin_port; /* 端口号,要用网络字节序表示 */
struct in_addr sin_addr; /* IPv4地址结构体,见下面 */
};
struct in_addr {
u_int32_t s_addr; /* IPv4地址,要用网络字节序表示* /
};
struct sockaddr_in6 {
sa_family_t sin6_family; /* 地址族:AF_INET6 */
u_int16_t sin6_port; /* 端口号,要用网络字节序表示 */
u_int32_t sin6_flowinfo; /* 流信息,应设置为0 */
struct in6_addr sin6_addr; /* IPv6地址结构体,见下面 */
u_int32_t sin6_scope_id; /* scope ID,尚处于实验阶段 */
};
struct in6_addr {
unsigned char sa_addr[16]; /* IPv6地址,要用网络字节序表示 */
};
所有专用 socket 地址 类型的 变量 在实际使用时 都需要 转化为 通用 socket 地址 类型sockaddr(强制转换即可),因为所有 socket 编程接口 使用的地址参数的 类型 都是 sockaddr。
(4)IP地址转换函数
#include<arpa/inet.h>
in_addr_t inet_addr(const char* strptr);
int inet_aton(const char* cp,struct in_addr* inp);
char*inet_ntoa(struct in_addr in);
① inet_addr函数
将用 点分 十进制字符串 表示的 IPv4 地址转化为 用网络字节序 整数 表示的 IPv4 地址。它失败时返回 INADDR_NONE。
② inet_aton函数
③ inet_ntoa函数
注:该函数内部用一个静态变量存储转化结果,函数的返回值指向该静态内存,因此inet_ntoa是不可重入的。
#include<arpa/inet.h>
int inet_pton(int af, const char* src, void* dst);
const char*inet_ntop(int af, const void* src, char* dst, socklen_t cnt);
④ inet_pton函数
⑤ inet_ntop函数
2. 创建socket
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
① domain参数
② type参数
对 TCP/IP 协议族而言,其值取 SOCK_STREAM 表示传输层使用 TCP协议,取SOCK_DGRAM 表示传输层使用 UDP 协议。
③ protocol参数
3. 命名socket
将一个 socket 与 socket 地址绑定称为 给 socket 命名。
在 服务器程序中,通常 要命名 socket,因为 只有命名后 客户端才能 知道该 如何连接 它。客户端则 通常不需要命名 socket,而是 采用 匿名方式,即 使用操作系统 自动分配的 socket地址。
#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
4. 监听socket
还需使用系统调用 来创建一个监听队列以 存放待处理的 客户连接。
#include<sys/socket.h>
int listen(int sockfd, int backlog);
① sockfd参数
指定被监听的 socket。
② backlog参数
提示 内核监听队列的 最大长度。监听队列的 长度如果超过 backlog,服务器 将不受理 新的客户连接,客户端也将收到 ECONNREFUSED 错误信息。
5. 接受连接
#include<sys/types.h>
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
① sockfd参数
② addr参数用来
accept 只是 从监听队列中 取出连接,而 不论连接处于 何种状态(如 ESTABLISHED 状态 和 CLOSE_WAIT 状态),更不关心 任何网络状况的 变化。我们 把执行过 listen 调用、处于 LISTEN 状态的 socket 称为 监听 socket,而 所有 处于 ESTABLISHED 状态的 socket 则称为连接 socket
6. 发起连接
#include<sys/types.h>
#include<sys/socket.h>
int connect(int sockfd, const struct sockaddr* serv_addr, socklen_t addrlen);
① sockfd参数
② serv_addr参数
③ addrlen参数
7. 关闭连接
#include<unistd.h>
int close(int fd);
close 系统 调用 并非总是 立即关闭一个连接,而是 将 fd 的引用 计数 减 1。只有 当 fd 的引用 计数为 0 时,才真 正关闭连接。多 进程程序 中,一次 fork 系统调用 默认将 使父进程中 打开的 socket 的引用计数 加 1,因此 必须在 父进程和子进程 中 都对 该 socket 执行 close 调用 才能将连接关闭。
如果 无论如何都要立即 终止连接(而不是将 socket 的引用 计数 减 1),可以使用 shutdown系统调用:
#include<sys/socket.h>
int shutdown(int sockfd, int howto);
① sockfd参数
② howto参数
由此可见,shutdown 能够分别关闭 socket 上的 读或写,或者 都关闭。而 close 在关闭连接时只能将 socke t上的读 和 写同时关闭。
8. 数据读写(TCP)
#include<sys/types.h>
#include<sys/socket.h>
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
ssize_t send(int sockfd, const void* buf, size_t len, int flags);
① recv
② send
9. 地址信息函数
#include<sys/socket.h>
int getsockname(int sockfd, struct sockaddr* address, socklen_t* address_len);
int getpeername(int sockfd, struct sockaddr* address, socklen_t* address_len);
① getsockname
获取 sockfd 对应的 本端 socket 地址,并将其 存储于 address 参数指定的 内存中,该 socket 地址的 长度则 存储于 address_len 参数 指向的 变量中。如果实际 socket 地址的 长度 大于 address 所指内存区的 大小,那么该 socket 地址将 被截断。
② getpeername
获取sockfd对应的远端socket地址。
10. 网络信息API
#include<netdb.h>
struct hostent* gethostbyname(const char* name); // 根据主机名称获取主机的完整信息
struct hostent* gethostbyaddr(const void* addr, size_t len, int type); // 根据IP地址获取主机的完整信息。
① name参数
② addr参数
③ len参数
④ type参数
gethostbyname 函数通常先 在本地的 /etc/hosts 配置文件中 查找主机,如果 没有找到,再去访 问 DNS 服务器。
#include<netdb.h>
struct hostent {
char* h_name; /* 主机名 */
char** h_aliases; /* 主机别名列表,可能有多个 */
int h_addrtype; /* 地址类型(地址族)*/
int h_length; /* 地址长度 */
char** h_addr_list /* 按网络字节序列出的主机IP地址列表 */
};
四、高效的事件处理模式
1. Reactor模式
Reactor 要求主线程(I/O 处理单元)只负责 监听文件描述上 是 否有事件发生,有的 话 就立即 将该事件通知 工作线程(逻辑单元)。除此之外,主线程 不做任何其他 实质性的工作。读写 数据,接受 新的连接,以及处理 客户请求 均在工作线程 中完成。
(1)主线程 往 epoll 内核事件表 中注册 socket 上的 读就绪事件。(2)主线程 调用 epoll_wait 等待 socket 上有数据 可读。(3)当 socket 上有数据 可读时,epoll_wait 通知 主线程。主线程 则将 socket 可读 事件放入 请求队列。(4)睡眠 在请求队列上的 某个工作线程 被唤醒,它从 socket 读取数据,并 处理 客户请求,然后往 epoll 内核事件表中 注册该 socket 上的 写就绪事件。(5)主线程 调用 epoll_wait 等待 socket 可写。(6)当 socket 可写时,epoll_wait 通知 主线程。主线程 将 socket 可写事件 放入 请求队列。(7)睡眠 在请求队列上 的某个 工作线程 被唤醒,它往 socket 上写入 服务器处理客户 请求的结果。
2. Proactor模式
Proactor模式将所有 I/O 操作都交给 主线程 和 内核来处理,工作线程 仅仅负责业务逻辑。
(1)主线程调用 aio_read 函数 向内核 注册 socket 上的 读完成事件,并告诉 内核 用户读缓冲区 的位置,以及 读操作完成 时如何 通知 应用程序(以 信号为例)。(2)主线程继续处理其他逻辑。(3)当 socket 上的数据 被读入 用户缓冲区后,内核将 向 应用程序 发送一个信号,以 通知应用 程序数据 已经可用。(4)应用程序 预先定义好 的信号处理函数 选择一个 工作线程 来 处理客户 请求。工作 线程处理完 客户请求之后,调用 aio_write 函数 向内核 注册 socket 上的 写完成事件,并 告诉内核 用户 写缓冲区的 位置,以及 写操作 完成 时如何 通知 应用程序(以信号为例)。(5)主线程 继续 处理其他逻辑。(6)当用户缓冲区的 数据 被写入 socket 之后,内核将 向应用程序 发送一个 信号,以 通知应用程序 数据 已经发送 完毕。(7)应用程序 预先定义好 的信号处理函数 选择一个 工作线程 来 做善后处理,比如 决定是否关闭 socket。
五、I/O复用
1. select系统调用
#include<sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
① nfds参数
② readfds、writefds和exceptfds参数
select 调用返回时,内核 将修改它们 来通 知应用程序 哪些文件描述符 已经 就绪。
#include<sys/select.h>
FD_ZERO(fd_set* fdset); /* 清除 fdset 的所有位 */
FD_SET(int fd, fd_set* fdset); /* 设置 fdset 的位fd */
FD_CLR(int fd, fd_set* fdset); /* 清除 fdset 的位fd */
int FD_ISSET(int fd, fd_set* fdset); /* 测试 fdset 的位fd是否被设置 */
③ timeout参数
struct timeval {
long tv_sec; /* 秒数 */
long tv_usec; /* 微秒数 */
};
由以上 定义可见,select 给我们提供了 一个微秒级 的 定时方式。如果给 timeout 变量的 tv_sec 成员 和 tv_usec 成员都 传递 0,则 select 将立即 返回。如果给 timeout 传递 NULL,则 select 将一直 阻塞,直到 某个文件描述符 就绪。
2. poll系统调用
#include<poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
① fds参数
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 注册的事件 */
short revents; /* 实际发生的事件,由内核填充 */
};
② nfds参数
③ timeout参数
3. epoll系列系统调用
#include<sys/epoll.h>
int epoll_create(int size)
以下函数用来操作 epoll 的内核事件表:
#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event)
① fd参数
② op参数
③ event参数
struct epoll_event {
__uint32_t events; /* epoll事件 */
epoll_data_t data; /* 用户数据 */
};
其中 events 成员描述 事件类型。epoll支 持的 事件类型 和 poll 基本相同。data 成员 用于存储用户 数据。
typedef union epoll_data {
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_ctl 成功时 返回 0,失败则 返回 -1 并设置 errno。