自制服务器1

服务端—客户端通信大致流程

  • 服务端创建一个监听套接字(对创建的监听套接字进行判断,是否创建成功,返回非负整数则代表创建成功,返回-1代表创建失败),调用函数socket()。在linux系统中一切皆文件,每个文件都有一个整数类型的文件描述符。socket()也是一个文件,也有文件描述符。使用socket()函数创建套接字以后,返回值就是一个int类型的文件描述符。

    linux下使用<sys/socket.h>头文件中socket()函数来创建套接字,函数原型为:

    1
    int socket(int af, int type, int protocol);

    其中,第一个参数af为地址族(Address Family),也就是IP地址类型,常用的IP地址类型有AF_INETAF_INET6AF”Address Family“的简写,INET”Internet“的简写。AF_INET表示IPv4地址,例如127.0.0.1AF_INET6表示IPv6地址,例如1030::C9B4:FF12:48AA:1A2B127.0.0.1是一个特殊的IP地址,表示本机地址。

    第二个参数type为数据传输方式/套接字类型,常用的有SOCK_STREAM(流格式套接字/面向连接的套接字)和SOCK_DGRAM(数据报套接字/无连接的套接字)。

    第三个参数protocol表示传输协议,常用的有IPPROTO_TCPIPPTOTO_UDP,即所谓的:TCPUDP

我们经常会看见别人程序里创建套接字如下所示:

1
2
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int listenfd = socket(AF_INET, SOCK_DGRAM, 0);

很多人可能会好奇为什么第三个参数会设置为0?其实既满足IP地址类型为:AF_INET又满足套接字类型为:SOCK_STREAM/SOCK_DGRAM的只有TCP/UDP,系统会自动推演出来应该使用什么协议,此时则可以将protocol设置为0。

<semaphore.h>头文件

<semphore.h>头文件是POSIX标准中定义的一个头文件,用于提供信号量的函数和数据类型。

信号量是一种用于提供不同进程或线程之间进行同步或者互斥的简单抽象。通常,信号量被用来限制对特定资源的访问,这在多线程和多进程环境中非常有用。

<semaphore.h>头文件中,定义了以下的函数:

  • sem_init():初始化一个未命名的信号量
  • sem_destroy():销毁一个未命名的信号量
  • sem_open():打开一个命名信号量,如果该信号量不存在,则创建一个
  • sem_close():关闭一个命名信号量
  • sem_unlink():删除一个命名信号量
  • sem_post():增加(解锁)信号量的值
  • sem_wait():减少(锁定)信号量的值,如果信号量的值为0,则调用该函数的线程会阻塞,直到信号量的值大于0
  • sem_trywait():尝试减少信号量的值,如果信号量的值为0,则立即返回错误,而不会阻塞
  • sem_getvalue():获取信号量的当前值
为什么要转换为sockaddr

所有专用socket地址(以及sockaddr_storage)类型的变量在实际使用时都需要转换为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr

网络服务中的三种端口:知名端口、注册端口、动态或私有端口

服务端口(Service Port)是计算机网络中,网络服务使用的网络端口。这些端口通常被分为三个范围:知名端口、注册端口和动态或私有端口。

知名端口(Well-Known Ports)是从0到1023的端口号。它们通常被系统进程或者管理员级别的进程使用。这些端口通常关联到一些广泛使用的网络服务,例如:

  • HTTP:端口80
  • HTTPS:端口443
  • FTP:端口20和21
  • SSH:端口22
  • Telnet:端口23
  • DNS:端口53
  • SMTP:端口25
  • POP3:端口110
  • IMAP:端口143

由于这些服务广泛地被用于许多网络应用,所以这些端口号被称为“知名端口”。这些端口的分配和维护由互联网号码分配局(Internet Assigned Numbers Authority,简称IANA)进行。

需要注意的是,除了这些知名端口,其他的端口也可以被任何应用程序使用,只要它们具备了适当的权限和配置。对于某些特定的应用程序,开发者可能会选择在1024-49151的范围内选择一个注册端口(Registered Ports),或者在49152–65535的范围内选择一个动态或私有端口(Dynamic or Private Ports)。

signal函数

在网络编程中,signal函数是一个非常重要的函数,它被用于设置处理特定信号的函数。

信号(signal)是一种在 Unix 系统(包括 Linux)中用于进程间通信的方式。它类似于一个异步的中断,当一个进程接收到一个信号时,操作系统会暂停该进程的正常执行流程,并去执行与该信号关联的处理函数。

signal函数的原型如下:

1
2
#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

这个函数接收两个参数:

  1. signum:这是一个整数,表示信号的类型。例如,SIGINT表示中断信号(通常是用户按下了Ctrl+C),SIGABRT表示一个异常条件,等等。

  2. handler:这是一个函数指针,指向用于处理这个信号的函数。处理函数的原型应该为void func(int),也就是说,它应该是一个接收整数参数并且没有返回值的函数。你可以提供自己的处理函数,也可以使用两个预定义的值:SIG_IGN表示忽略这个信号,SIG_DFL表示使用系统默认的处理方式。

signal函数的返回值是一个函数指针,它指向之前设置的处理函数(如果有的话)。如果函数执行失败,那么返回的将是SIG_ERR

需要注意的是,signal函数并不是 POSIX 标准的一部分,不同的系统可能会有不同的行为。在进行跨平台编程时,通常建议使用sigaction函数代替signal,因为sigaction提供了更多的控制选项,并且它的行为在不同的系统上更加一致。

SIGINTSIGABRT等都是预定义的宏,这些宏在signal.h或者其他相关的头文件中定义,它们的值是整数。例如,在许多系统中,SIGINT的值是2,但这个值并不是在所有系统中都一样。

这些宏名(例如SIGINT)只是为了在代码中更好地表示不同的信号,使得代码更易于理解。实际上,当我们在代码中写SIGINT时,编译器会将其替换为对应的整数值。

所以,当我们调用signal(SIGINT, handler)时,我们实际上是在说:“我想将处理SIGINT信号的函数设置为handler。”这里的SIGINT在被编译器处理后,就是一个整数值,代表了中断信号。

telnet命令

telnet是一个网络协议和一个用于在互联网或局域网上执行此协议的命令行工具。它基于文本,主要用于远程管理网络设备和运行服务器端应用。然而,请注意,由于telnet不提供任何加密的方式,所以任何通过telnet发送的数据,包括密码,都是明文的,因此它已经被认为是不安全的,并且在许多新的系统和设备中已经被SSH(Secure Shell)所取代。

基本的telnet命令的语法如下:

1
telnet [host] [port]

参数解释:

  • host:这是你想要连接的远程主机的IP地址或主机名。例如,如果你想要连接到位于192.168.1.1的主机,你可以输入telnet 192.168.1.1

  • port:这是你想要连接到的远程服务的端口号。这个参数是可选的,如果你不提供,telnet会默认连接到端口23,这是Telnet协议的标准端口。例如,如果你想要测试一个位于192.168.1.1的主机的80端口(通常是HTTP服务器),你可以输入telnet 192.168.1.1 80

telnet连接建立之后,你可以输入任何文本并按Enter键发送。当你想要断开连接时,你可以输入特殊的telnet命令,这通常是Ctrl+](输入Ctrl]键),然后输入quit

webServer服务器整体描述

本项目主要目的就是对浏览器的链接请求进行处理并给予相应响应。对于请求的处理,系统整体运行流程为:主线程对I/O进行监听,获取I/O请求后把请求放入请求队列,等待工作线程处理。工作线程对于请求的处理(HTTP报文的解析)主要采用状态机思想来解析HTTP报文的解析。

多路I/O复用

多路 I/O 复用的基本概念是,一个进程使用一个线程来监控多个文件描述符(通常是套接字描述符),当其中任何一个文件描述符准备好进行读或写操作时,该线程就可以进行相应的处理。这样,一个线程就可以同时处理多个 I/O 事件,提高了系统的并发性。常用的多路 I/O 复用的系统调用有 selectpollepoll

select

select是Unix/Linux系统下的一种I/O多路复用机制。它允许应用程序监视多个文件描述符,等待一个或多个描述符成为就绪状态,以进行I/O操作(例如读或写)。这是一种异步I/O模型,因为你不需要阻塞等待单个I/O操作的完成。

select的函数原型如下:

1
2
3
#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数解释:

  • nfds:需要监视的文件描述符数量,通常设置为所监视文件描述符中的最大值加1。
  • readfds:需要监视读状态的文件描述符集合。
  • writefds:需要监视写状态的文件描述符集合。
  • exceptfds:需要监视异常状态的文件描述符集合。
  • timeout:等待就绪的最长时间。如果设为NULL,则表示无限等待。

select函数返回准备好的文件描述符的数量,如果超时则返回0,如果出错则返回-1。

fd_set是一种数据结构,用于存储文件描述符集合。你可以使用以下的宏来操作这个集合:

  • FD_ZERO(fd_set *fdset): 清除一个文件描述符集合。
  • FD_SET(int fd, fd_set *fdset): 添加一个新的文件描述符到集合中。
  • FD_CLR(int fd, fd_set *fdset): 从集合中移除一个文件描述符。
  • FD_ISSET(int fd, fd_set *fdset): 检查集合中的一个文件描述符是否就绪。

注意,在调用select函数后,这些集合会被修改,只包含那些状态发生变化(即准备好进行读或写操作)的文件描述符。因此,如果你想要在一个循环中重复使用select,你需要在每次调用select前重新设置这些集合。

虽然select在一些场景下非常有用,但是它有一些已知的限制,例如最大的文件描述符数量(通常是1024),以及处理大量文件描述符时的效率问题。在这些情况下,你可能需要使用其他的I/O多路复用机制,例如pollepoll

tip:这是一种异步I/O模型,因为你不需要阻塞等待单个I/O操作的完成。

当我们说一个I/O操作是“阻塞”的,这意味着执行这个操作的线程或进程在操作完成之前会被挂起。例如,如果你在一个阻塞的套接字上执行读操作,而这个套接字当前并没有接收到任何数据,那么执行读操作的线程就会被挂起,直到有数据到达。在这个过程中,线程不能做任何其他的事情。

相反,“非阻塞”的I/O操作意味着即使操作不能立即完成,执行这个操作的线程或进程也不会被挂起。例如,如果你在一个非阻塞的套接字上执行读操作,而这个套接字当前并没有接收到任何数据,那么读操作会立即返回一个错误,而线程可以继续做其他的事情。

I/O多路复用(例如select、poll或epoll)提供了一种异步的I/O模型。在这个模型中,你可以同时监视多个文件描述符,并在任何一个描述符准备好进行I/O操作时得到通知。这样,你就可以在等待I/O操作的同时做其他的事情,或者同时处理多个I/O操作。这就是为什么我们说这是一个“异步”的模型:你不需要为每个I/O操作分别阻塞等待。

例如,在上述select的例子中,服务器可以同时处理多个客户端的请求。当任何一个客户端的套接字准备好进行读操作(即有数据到达)时,select函数就会返回,然后服务器可以读取这个套接字上的数据。在这个过程中,服务器并不需要为每个客户端分别阻塞等待。

通俗解释一下:

你想从你家到县城去,阻塞方式是:你去车站等车,等车期间你哪也不能去,就干等到车来;非阻塞方式,你在家等车,这期间你想干啥就干啥,等车来了,司机会滴喇叭,跟你说快上车。

让我们通过一个简单的服务器代码示例来进一步理解select的使用。这个示例是一个TCP回显服务器,它使用select来同时处理多个客户端的连接和请求。

这是一个非常基础的示例,它没有处理一些可能的错误情况,例如文件描述符耗尽,以及select的最大文件描述符限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024

int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

listen(listen_fd, MAX_CLIENTS);

fd_set read_fds;
int max_fd = listen_fd;
int clients[MAX_CLIENTS] = {0};

while (1) {
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds);

for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i] > 0) {
FD_SET(clients[i], &read_fds);
if (clients[i] > max_fd) {
max_fd = clients[i];
}
}
}

select(max_fd + 1, &read_fds, NULL, NULL, NULL);

if (FD_ISSET(listen_fd, &read_fds)) {
int client_fd = accept(listen_fd, NULL, NULL);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i] == 0) {
clients[i] = client_fd;
break;
}
}
}

for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i] > 0 && FD_ISSET(clients[i], &read_fds)) {
char buffer[BUFFER_SIZE];
int len = read(clients[i], buffer, BUFFER_SIZE - 1);
if (len > 0) {
buffer[len] = '\0';
printf("Received from client: %s\n", buffer);
write(clients[i], buffer, len);
} else {
close(clients[i]);
clients[i] = 0;
}
}
}
}

return 0;
}

这个程序首先创建一个监听套接字,然后进入一个无限循环,在循环中调用select函数等待新的连接或者已连接的客户端的请求。每次循环都会重新设置文件描述符集合,添加监听套接字以及所有已连接的客户端的套接字。

如果select函数返回并指示监听套接字已经准备好(有新的连接请求),程序就接受新的连接,并把新的客户端套接字添加到客户端数组中。

如果select函数返回并指示某个客户端套接字已经准备好(有新的数据到达),程序就

读取这个套接字上的数据,然后回显(即原样返回)这些数据。如果读取操作返回0或负值(表示连接已经关闭或出错),程序就关闭这个套接字,并把它从客户端数组中移除。

这个示例展示了如何使用select处理多个客户端的连接和请求。然而在实际的程序中,你需要处理更多的问题,例如select的最大文件描述符限制,以及文件描述符耗尽的问题。在处理大量连接时,你可能需要考虑使用其他的I/O多路复用技术,例如pollepoll

max_fd到底有啥作用呢?select函数第一个参数我为啥感觉没啥作用呢?

select函数的第一个参数,即nfds,表示需要监视的文件描述符的范围,它的值应该是所有文件描述符中最大的那个加1。

为什么需要最大文件描述符呢?

原因是select函数在内部实现时会遍历所有的文件描述符,从0到nfds - 1select函数将检查这个范围内的每一个文件描述符,看看它是否在readfdswritefdsexceptfds集合中,并且它的状态是否发生了变化(即是否准备好进行读、写或者是否有异常)

实际上,select不关心文件描述符是否连续,它只是检查你指定的范围内的每一个文件描述符。因此,如果你的程序中最大的文件描述符是10,那么你就应该把nfds设为11,即使你的程序并没有使用文件描述符0到9。

在设置nfds时,使用最大文件描述符加1是一个优化的选择。如果你把nfds设得过大,select函数就会浪费时间去检查那些并不存在的文件描述符。反之,如果你把nfds设得过小,select函数就会忽略那些超出范围的文件描述符,即使它们在readfdswritefdsexceptfds集合中,并且状态发生了变化。

因此,max_fd在这里的作用就是帮助我们正确地设置select函数的nfds参数,以便让select函数高效地监视我们关心的所有文件描述符。

代码里面貌似自始至终也没体现select函数的作用,因为连select函数的返回值都没用上,就好比现在select现在监听1 2 3 4 5 6这6个文件描述符,假设5准备就绪了,我怎么知道5准备就绪了呢,在你的代码貌似并没有体现出来

实际上,当select函数返回后,我们可以通过检查read_fds集合来确定哪些文件描述符已经准备就绪。

具体来说,如果FD_ISSET(fd, &read_fds)为真,那么就表示fd已经准备好进行读操作。在我的示例代码中,这个检查分别在处理监听套接字和客户端套接字时进行。

如果select函数指示监听套接字已经准备好(有新的连接请求),那么我们就接受新的连接:

1
2
3
4
if (FD_ISSET(listen_fd, &read_fds)) {
int client_fd = accept(listen_fd, NULL, NULL);
// 添加新的客户端到 clients 数组...
}

如果select函数指示某个客户端套接字已经准备好(有新的数据到达),那么我们就读取这个套接字上的数据:

1
2
3
4
5
6
7
for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i] > 0 && FD_ISSET(clients[i], &read_fds)) {
char buffer[BUFFER_SIZE];
int len = read(clients[i], buffer, BUFFER_SIZE - 1);
// 处理读取到的数据...
}
}

这个示例代码的目的是展示select函数如何用于处理多个连接和请求。在实际的应用中,你可能还需要处理更多的情况,例如写就绪和异常,以及select函数的返回值(表示已经准备就绪的文件描述符的数量)。

poll

有了select的基础,理解poll起来就方便一点啦。poll函数是另一种I/O多路复用的技术。与select相比,它的主要优点是没有文件描述符的数量限制,而且API使用起来可能更加直观。poll函数使用一个pollfd结构体数组,其中每个结构体都包含一个文件描述符和一些标志位,表示我们对哪些事件感兴趣(例如POLLIN表示我们对读事件感兴趣)。

下面是一个简单的使用poll的服务器代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <poll.h>

#define MAX_CLIENTS 1024
#define BUFFER_SIZE 1024

int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(12345);

bind(listen_fd, (struct sockaddr*)&servaddr, sizeof(servaddr));

listen(listen_fd, 10);

struct pollfd clients[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++) {
clients[i].fd = -1; // -1表示该位置没有使用
clients[i].events = POLLIN; // 对读事件感兴趣
}
clients[0].fd = listen_fd; // 监听套接字始终在clients[0]的位置

while (1) {
int nready = poll(clients, MAX_CLIENTS, -1);

if (clients[0].revents & POLLIN) {
// 监听套接字准备好了,表示有新的连接请求
int client_fd = accept(listen_fd, NULL, NULL);
// 将新的连接添加到clients数组中
for (int i = 1; i < MAX_CLIENTS; i++) {
if (clients[i].fd < 0) {
clients[i].fd = client_fd;
break;
}
}
}

for (int i = 1; i < MAX_CLIENTS; i++) {
if (clients[i].fd < 0) {
continue;
}
if (clients[i].revents & (POLLIN | POLLERR)) {
// 客户端套接字准备好了,表示有数据可以读取
char buffer[BUFFER_SIZE];
int len = read(clients[i].fd, buffer, BUFFER_SIZE - 1);
if (len <= 0) {
// 连接已经关闭或出错,将该客户端从数组中移除
close(clients[i].fd);
clients[i].fd = -1;
} else {
// 将接收到的数据回显给客户端
write(clients[i].fd, buffer, len);
}
}
}
}

return 0;
}

在这个示例中,服务器使用poll函数同时监视所有连接的客户端套接字。每次当poll函数返回时,服务器会检查哪些套接字已经准备好进行I/O操作,并相应地处理它们。

clients[0].revents & POLLIN这个代表啥意思

这是在检查poll函数返回后哪些文件描述符发生了事件。

clients[0].revents & POLLIN这个表达式使用了位运算符&来检查revents字段的某个特定位(在这个例子中是POLLIN位)是否被设置。如果结果非零,那么说明POLLIN事件发生了,也就是说对应的文件描述符已经准备好进行读操作。

这里的POLLIN是一个常量,表示可以从文件描述符中读取数据。revents字段则是poll函数返回后设置的,表示在调用poll函数期间,哪些事件发生了。

如果clients[0].revents & POLLIN为真,那么就表示监听套接字已经准备好,我们可以从这个套接字上接受新的连接。

这个能不能给出一个实例,不太理解

当然可以,让我们试着用一个简单的例子来理解这个概念。

首先,我们要知道POLLINrevents都是整型(int)变量,也就是说,它们都可以表示为二进制数。

假设POLLIN的值是4,也就是二进制的100,而revents的值是5,也就是二进制的101

我们用&(位运算符AND)来对这两个值进行操作,得到的结果是100,也就是4,非零值。

1
2
3
4
  POLLIN:  100 (二进制)
revents: 101 (二进制)
----------------------
结果: 100 (二进制) = 4 (十进制)

这个结果是非零的,所以if (clients[0].revents & POLLIN)的判断结果是真。这就表示reventsPOLLIN位被设置了,也就是说,对应的文件描述符已经准备好进行读操作了。

如果revents的值是2(二进制010),那么结果就是零:

1
2
3
4
  POLLIN:  100 (二进制)
revents: 010 (二进制)
----------------------
结果: 000 (二进制) = 0 (十进制)

这个结果是零,所以if (clients[0].revents & POLLIN)的判断结果是假。这就表示reventsPOLLIN位没有被设置,也就是说,对应的文件描述符还没有准备好进行读操作。

所以,这个表达式就是在检查revents是否表示文件描述符已经准备好进行读操作。

recv函数

socket编程中,recv函数的返回值有以下三种情况:

  1. recv函数返回0时,表示连接已经关闭,也就是说,另一端已经执行了close操作。

  2. recv函数返回-1时,表示接收数据时发生了错误,你可以通过检查全局变量errno来确定具体的错误类型。在非阻塞模式下,如果没有可读取的数据,recv函数也会返回-1,并且errno会被设置为EAGAIN或者EWOULDBLOCK

  3. 如果返回值大于0,表示成功接收到了数据。

在你给出的代码中,if (ret <= 0)这个判断语句实际上就是在处理以上的前两种情况。也就是说,如果连接已经关闭,或者在接收数据时发生了错误,就关闭socket

在你的例子中,ret=0实际上并不是代表LT模式下未响应此事件,而是表示对端关闭了连接。因此,关闭这个socket是合理的,因为这个连接已经没有用了。即使接收缓冲区里还有未读取的数据,这些数据也无法被发送到已经关闭的对端,所以就没有必要保持这个socket打开了。