1. socket API
- socket() 根据指定的地址族、数据类型和协议来分配一个套接口的描述字及其所用资源的函数
int socket(int domain,int type,int protocol);
domain: 协议族/簇 AF_INET(IPv4), AF_INET6(IPv6)
type:套接口类型:SOCK_STREAM(TCP), SOCK_DGRAM(UDP)
protocol: 一般为0
返回创建成功的socketfd,如果创建失败返回-1.
- bind() 绑定socket到本地地址和端口,通常由服务器调用。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd,需要绑定地址的socket fd
addr, 通用socket地址的指针,需要专用类型强制类型转换
addrlen, addr的大小,通常用sizeof取好
0绑定成功,-1发生错误
- listen() TCP专用,开启监听模式。服务端socket完成地址绑定后,需要创建监听队列并开始socket监听绑定的端口,将待处理的客户端连接存放在监听队列中。
int listen(int sockfd, int backlog);
sockfd :被监听的socket,因此服务端通过socket函数创建的socket fd一般也叫做listen fd
backlog:客户端发起的连接等待accept,在socket fd监听等待队列最大长度。
- accept() TCP专用,服务器等待客户连接,一般是阻塞态。从已完成的连接队列中返回下一个简历成功的连接,如果已完成连接队列为空,则线程进入阻塞态等待睡眠状态,成功时返回套接字描述符,错误时返回-1。
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
sockfd:服务端socket创建的listen fd,必须成功调用过bind和listen
address:返回建立连接的客户端地址,如果不关心设置为NULL
address_len:address_len变量长度,直接调sizeof即可
返回:接受连接后创建对应的socket fd,也被叫做accept fd
- connect() TCP专用,客户端主动连接服务器。实际是发起三次握手过程,连接成功返回0,失败返回-1。
int connect(int socket, const struct sockaddr *address, socklen_t address_len);
socket:客户端发起连接的socket fd,也有叫做conn fd
address:发起连接的目标服务端地址通用地址结构指针,专用地址结构强制类型转换。这个地址文档也叫peer address。
address_len:address内存长度,调用sizeof即可。
返回:0连接成功建立,-1失败并设置errno
- send() TCP专用,发送数据。
- recv() TCP专用,接收数据。
- sendto() UDP专用,发送数据到指定的IP地址和端口。
- recvfrom() UDP专用,接收数据,返回数据远端的IP地址和端口。
send/sendto/recv/recvfrom和read/write比较相似,不过也有一些注意点:
flags:socket IO的选项见下面的表格,通常设置为0
返回值的话一般recv...
返回实际接受到的数据长度,0说明对方连接关闭,-1说明出错。send...
会返回实际写入数据长度,失败返回-1。返回的结果可能会小于函数输入的期望参数len,因此函数可能需要多次调用。
- closesocket() 关闭socket。
2. 代码实例
server.c
从客户端读字符,然后将每个字符转换为大写并回送给客户端。
/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAXLINE 80
#define SERV_PORT 8000
int main(void)
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
int i, n;
listenfd = socket(AF_INET, SOCK_STREAM, 0); //IPv4+TCP协议
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
//将listenfd绑定到本地地址与端口
listen(listenfd, 20);
//将listenfd改为监听套接字描述符
printf("Accepting connections ...\n");
while (1) {
//循环接收
cliaddr_len = sizeof(cliaddr);
connfd = accept(listenfd,
(struct sockaddr *)&cliaddr, &cliaddr_len);
//将接收到的客户端地址 保存到cliaddr中
n = read(connfd, buf, MAXLINE);
//读取连接到的客户端套接字中的信息
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
//打印接收信息的地址和借口消息
for (i = 0; i < n; i++)
buf[i] = toupper(buf[i]);
//接收消息
write(connfd, buf, n);
//写回连接的fd
close(connfd);//写完该socket关闭文件描述符。
}
}
client.c
/* client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAXLINE 80
#define SERV_PORT 8000
int main(int argc, char *argv[])
{
struct sockaddr_in servaddr;
char buf[MAXLINE];
int sockfd, n;
char *str;
if (argc != 2) {
fputs("usage: ./client message\n", stderr);
exit(1);
}
str = argv[1];
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);
connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
write(sockfd, str, strlen(str));
n = read(sockfd, buf, MAXLINE);
printf("Response from server:\n");
write(STDOUT_FILENO, buf, n);
close(sockfd);
return 0;
}
3. 使用fork并发处理多个client的请求
server.c
/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAXLINE 80
#define SERV_PORT 8000
int main(void)
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
int i, n;
listenfd = socket(AF_INET, SOCK_STREAM, 0); //IPv4+TCP协议
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
//将listenfd绑定到本地地址与端口
listen(listenfd, 20);
//将listenfd改为监听套接字描述符
printf("Accepting connections ...\n");
while (1) {
//循环接收
cliaddr_len = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
//将接收到的客户端地址 保存到cliaddr中
printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),ntohs(cliaddr.sin_port));
//打印接收信息的地址和借口消息
pid=fork();
if (pid == -1)
ERR_EXIT("fork error");
if (pid == 0)
{
// 子进程
close(listenfd); //子进程已经不再需要使用到监听描述符,因此将其关闭。
//==================处理子进程的连接任务=============================
n = read(connfd, buf, MAXLINE);
//读取连接到的客户端套接字中的信息
for (i = 0; i < n; i++)
buf[i] = toupper(buf[i]);
//接收消息
write(connfd, buf, n);
close(connfd);
//写回连接的fd
//================================================================
exit(EXIT_SUCCESS);
}
else
close(connfd); //父进程也不需要该连接描述符了,因此将其关闭。
//下一轮循环会读取新的一个连接从而fork一个新的子进程处理该服务。
}
return 0;
}
4. Webserver 项目 技术栈
4.1 多路IO复用技术
select,poll,epoll之间的区别
-
调用函数
select和poll都是一个函数,epoll是一组函数 -
文件描述符数量
select通过线性表描述文件描述符集合,文件描述符有上限,一般是1024,但可以修改源码,重新编译内核,不推荐
poll是链表描述,突破了文件描述符上限,最大可以打开文件的数目
epoll通过红黑树描述,最大可以打开文件的数目,可以通过命令ulimit -n number修改,仅对当前终端有效 -
将文件描述符从用户传给内核
select和poll通过将所有文件描述符拷贝到内核态,每次调用都需要拷贝
epoll通过epoll_create建立一棵红黑树,通过epoll_ctl将要监听的文件描述符注册到红黑树上 -
内核判断就绪的文件描述符
select 和 poll 每次调用都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”
epoll 因为epoll内核种实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃,可能有性能问题。 -
应用程序索引就绪文件描述符
select/poll只返回发生了事件的文件描述符的个数,若知道是哪个发生了事件,同样需要遍历。
epoll返回的发生了事件的个数和结构体数组,结构体包含socket的信息,因此直接处理返回的数组即可。 -
工作模式
select和poll都只能工作在相对低效的LT模式下
epoll则可以工作在ET高效模式,并且epoll还支持EPOLLONESHOT事件,该事件能进一步减少可读、可写和异常事件被触发的次数。
一个socket连接在任一时刻都只被一个线程处理,可以使用 epoll 的 EPOLLONESHOT 事件实现。 -
应用场景
当所有的fd都是活跃连接,epoll需要建立文件系统,红黑树和链表对于此来说,效率反而不高,不如selece和poll
当监测的fd数目较小,且各个fd都比较活跃,建议使用select或者poll
当监测的fd数目非常大,成千上万,且单位时间只有其中的一部分fd处于就绪状态,这个时候使用epoll能够明显提升性能。
4.1.1 select
select是网络程序中很常用的一个系统调用,它可以同时监听多个阻塞的文件描述符(例如多个网络连接),哪个有数据到达就处理哪个,这样就不用fork和多进程就实现并发服务的server。
处理思路:
- 首先将listenfd初始化好,存入fdset,每轮循环加入一个监听队列中的客户端链接
- 然后轮流处理fdset中的各个连接,如果没有消息则直接断开连接,如果有消息则处理完毕再断开连接,两种情况都需要将该连接从集合中移出。
/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include "wrap.h"
#define MAXLINE 80
#define SERV_PORT 8000
int main(int argc, char **argv)
{
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
socklen_t cliaddr_len;
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
Listen(listenfd, 20);
maxfd = listenfd; /* initialize */
maxi = -1; /* index into client[] array */
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1; /* -1 indicates available entry */
FD_ZERO(&allset);
FD_SET(listenfd, &allset); //将listenfd加入allset
for ( ; ; ) {
rset = allset; /* structure assignment */
//当前集合
nready = select(maxfd+1, &rset, NULL, NULL, NULL);
//超时返回0,失败返回-1,成功返回就绪描述符的数目。
if (nready < 0)
perr_exit("select error");
if (FD_ISSET(listenfd, &rset)) { /* new client connection */
//如果listenfd在rset中,则继续
cliaddr_len = sizeof(cliaddr);
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
//读取一个连接描述符
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] < 0) {
client[i] = connfd; /* save descriptor */
break;
}
//client<0表示当前索引的描述符为空,将新接收的客户端connfd存入数组中。
if (i == FD_SETSIZE) {
fputs("too many clients\n", stderr);
exit(1);
}
//超出select管理数目上线
FD_SET(connfd, &allset); /* add new descriptor to set */
//将新的描述符加入fd集合中。
if (connfd > maxfd)
maxfd = connfd; /* for select */
if (i > maxi)
maxi = i; /* max index in client[] array */
//更新集合上限值
if (--nready == 0)
continue; /* no more readable descriptors */
}
for (i = 0; i <= maxi; i++) { /* check all clients for data */
//轮流处理集合中的连接
if ( (sockfd = client[i]) < 0)
continue; //跳过集合中的空描述符
if (FD_ISSET(sockfd, &rset)) {
if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
/* connection closed by client */
//如果连接中没有消息,则关闭连接
Close(sockfd);
FD_CLR(sockfd, &allset);
//将该描述符从集合中清除
client[i] = -1;
//描述符数组清空
} else {
int j;
for (j = 0; j < n; j++)
buf[j] = toupper(buf[j]);
Write(sockfd, buf, n);
//否则写回
}
if (--nready == 0)
break; /* no more readable descriptors */
//就绪的连接数目为0的话则跳出循环。
}
}
}
}
4.1.2 poll
4.1.3 epoll
select方法的缺点在于:首先,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。此外,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
Epoll的优化措施:
- 功能分离
select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
//epoll用法
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...);
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
- 就绪列表
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
epoll的原理和流程
-
创建epoll对象 epoll_create
当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。
-
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。 -
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。 -
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
epoll的实现细节
- 就绪列表的数据结构
就绪列表引用着就绪的socket,所以它应能够快速的插入数据。程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。 - 索引结构
既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)。
4.2 线程池
可以借助线程池,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等等),用于提高处理多请求的效率。
4.2.1 线程池中的并发处理模式
两种事件并发处理模式
- Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。
- Proactor模式:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后
users[sockfd].read()
,选择一个工作线程来处理客户请求pool->append(users + sockfd)
半异步: 异步处理I/O事件,就是客户端向服务器端的请求的接收,是通过异步线程进行处理的,来请求触发处理,没有来的时候处理其他事情。
半同步: 是指同步处理请求数据,异步线程接收完请求之后会封装一下插入队列,工作线程就依次同步从队列中取出请求对象进行处理。
并发模式中的同步和异步
同步指的是程序完全按照代码序列的顺序执行;
异步指的是程序的执行需要由系统事件驱动。
半同步/半异步模式工作流程
同步线程用于处理客户逻辑;
异步线程用于处理I/O事件;
异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。
请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
半同步/半反应堆工作流程(以Proactor模式为例)
主线程充当异步线程,负责监听所有socket上的事件;
若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件;
如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中;
所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权。