自制服务器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_INET
和AF_INET6
。AF
是”Address Family“
的简写,INET
是”Internet“
的简写。AF_INET
表示IPv4
地址,例如127.0.0.1
;AF_INET6
表示IPv6
地址,例如1030::C9B4:FF12:48AA:1A2B
。127.0.0.1
是一个特殊的IP
地址,表示本机地址。第二个参数
type
为数据传输方式/套接字类型,常用的有SOCK_STREAM
(流格式套接字/面向连接的套接字)和SOCK_DGRAM
(数据报套接字/无连接的套接字)。第三个参数
protocol
表示传输协议,常用的有IPPROTO_TCP
和IPPTOTO_UDP
,即所谓的:TCP
和UDP
。
我们经常会看见别人程序里创建套接字如下所示:
1 | int listenfd = socket(AF_INET, SOCK_STREAM, 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,则调用该函数的线程会阻塞,直到信号量的值大于0sem_trywait()
:尝试减少信号量的值,如果信号量的值为0,则立即返回错误,而不会阻塞sem_getvalue()
:获取信号量的当前值
为什么要转换为sockaddr
所有专用socket
地址(以及sockaddr_storage
)类型的变量在实际使用时都需要转换为通用socket
地址类型sockaddr
(强制转换即可),因为所有socket
编程接口使用的地址参数的类型都是sockaddr
。
网络服务中的三种端口:知名端口、注册端口、动态或私有端口
服务端口(Service Port
)是计算机网络中,网络服务使用的网络端口。这些端口通常被分为三个范围:知名端口、注册端口和动态或私有端口。
知名端口(Well-Known Ports
)是从0到1023的端口号。它们通常被系统进程或者管理员级别的进程使用。这些端口通常关联到一些广泛使用的网络服务,例如:
HTTP
:端口80HTTPS
:端口443FTP
:端口20和21SSH
:端口22Telnet
:端口23DNS
:端口53SMTP
:端口25POP3
:端口110IMAP
:端口143
由于这些服务广泛地被用于许多网络应用,所以这些端口号被称为“知名端口”。这些端口的分配和维护由互联网号码分配局(Internet Assigned Numbers Authority
,简称IANA
)进行。
需要注意的是,除了这些知名端口,其他的端口也可以被任何应用程序使用,只要它们具备了适当的权限和配置。对于某些特定的应用程序,开发者可能会选择在1024-49151的范围内选择一个注册端口(Registered Ports
),或者在49152–65535的范围内选择一个动态或私有端口(Dynamic or Private Ports
)。
signal
函数
在网络编程中,signal
函数是一个非常重要的函数,它被用于设置处理特定信号的函数。
信号(signal
)是一种在 Unix
系统(包括
Linux
)中用于进程间通信的方式。它类似于一个异步的中断,当一个进程接收到一个信号时,操作系统会暂停该进程的正常执行流程,并去执行与该信号关联的处理函数。
signal
函数的原型如下:
1 |
|
这个函数接收两个参数:
signum
:这是一个整数,表示信号的类型。例如,SIGINT
表示中断信号(通常是用户按下了Ctrl+C
),SIGABRT
表示一个异常条件,等等。handler
:这是一个函数指针,指向用于处理这个信号的函数。处理函数的原型应该为void func(int)
,也就是说,它应该是一个接收整数参数并且没有返回值的函数。你可以提供自己的处理函数,也可以使用两个预定义的值:SIG_IGN
表示忽略这个信号,SIG_DFL
表示使用系统默认的处理方式。
signal
函数的返回值是一个函数指针,它指向之前设置的处理函数(如果有的话)。如果函数执行失败,那么返回的将是SIG_ERR
。
需要注意的是,signal
函数并不是 POSIX
标准的一部分,不同的系统可能会有不同的行为。在进行跨平台编程时,通常建议使用sigaction
函数代替signal
,因为sigaction
提供了更多的控制选项,并且它的行为在不同的系统上更加一致。
SIGINT
、SIGABRT
等都是预定义的宏,这些宏在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 复用的系统调用有
select
、poll
、epoll
。
select
select
是Unix/Linux系统下的一种I/O多路复用机制。它允许应用程序监视多个文件描述符,等待一个或多个描述符成为就绪状态,以进行I/O操作(例如读或写)。这是一种异步I/O模型,因为你不需要阻塞等待单个I/O操作的完成。
select
的函数原型如下:
1 |
|
参数解释:
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多路复用机制,例如poll
或epoll
。
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 |
|
这个程序首先创建一个监听套接字,然后进入一个无限循环,在循环中调用select
函数等待新的连接或者已连接的客户端的请求。每次循环都会重新设置文件描述符集合,添加监听套接字以及所有已连接的客户端的套接字。
如果select
函数返回并指示监听套接字已经准备好(有新的连接请求),程序就接受新的连接,并把新的客户端套接字添加到客户端数组中。
如果select
函数返回并指示某个客户端套接字已经准备好(有新的数据到达),程序就
读取这个套接字上的数据,然后回显(即原样返回)这些数据。如果读取操作返回0或负值(表示连接已经关闭或出错),程序就关闭这个套接字,并把它从客户端数组中移除。
这个示例展示了如何使用select
处理多个客户端的连接和请求。然而在实际的程序中,你需要处理更多的问题,例如select
的最大文件描述符限制,以及文件描述符耗尽的问题。在处理大量连接时,你可能需要考虑使用其他的I/O多路复用技术,例如poll
或epoll
。
max_fd到底有啥作用呢?select函数第一个参数我为啥感觉没啥作用呢?
select
函数的第一个参数,即nfds
,表示需要监视的文件描述符的范围,它的值应该是所有文件描述符中最大的那个加1。
为什么需要最大文件描述符呢?
原因是select
函数在内部实现时会遍历所有的文件描述符,从0到nfds - 1
。select
函数将检查这个范围内的每一个文件描述符,看看它是否在readfds
、writefds
或exceptfds
集合中,并且它的状态是否发生了变化(即是否准备好进行读、写或者是否有异常)。
实际上,select
不关心文件描述符是否连续,它只是检查你指定的范围内的每一个文件描述符。因此,如果你的程序中最大的文件描述符是10,那么你就应该把nfds
设为11,即使你的程序并没有使用文件描述符0到9。
在设置nfds
时,使用最大文件描述符加1是一个优化的选择。如果你把nfds
设得过大,select
函数就会浪费时间去检查那些并不存在的文件描述符。反之,如果你把nfds
设得过小,select
函数就会忽略那些超出范围的文件描述符,即使它们在readfds
、writefds
或exceptfds
集合中,并且状态发生了变化。
因此,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 | if (FD_ISSET(listen_fd, &read_fds)) { |
如果select
函数指示某个客户端套接字已经准备好(有新的数据到达),那么我们就读取这个套接字上的数据:
1 | for (int i = 0; i < MAX_CLIENTS; i++) { |
这个示例代码的目的是展示select
函数如何用于处理多个连接和请求。在实际的应用中,你可能还需要处理更多的情况,例如写就绪和异常,以及select
函数的返回值(表示已经准备就绪的文件描述符的数量)。
poll
有了select的基础,理解poll起来就方便一点啦。poll
函数是另一种I/O多路复用的技术。与select
相比,它的主要优点是没有文件描述符的数量限制,而且API使用起来可能更加直观。poll
函数使用一个pollfd
结构体数组,其中每个结构体都包含一个文件描述符和一些标志位,表示我们对哪些事件感兴趣(例如POLLIN表示我们对读事件感兴趣)。
下面是一个简单的使用poll
的服务器代码示例:
1 |
|
在这个示例中,服务器使用poll
函数同时监视所有连接的客户端套接字。每次当poll
函数返回时,服务器会检查哪些套接字已经准备好进行I/O操作,并相应地处理它们。
clients[0].revents & POLLIN这个代表啥意思
这是在检查poll
函数返回后哪些文件描述符发生了事件。
clients[0].revents & POLLIN
这个表达式使用了位运算符&
来检查revents
字段的某个特定位(在这个例子中是POLLIN
位)是否被设置。如果结果非零,那么说明POLLIN
事件发生了,也就是说对应的文件描述符已经准备好进行读操作。
这里的POLLIN
是一个常量,表示可以从文件描述符中读取数据。revents
字段则是poll
函数返回后设置的,表示在调用poll
函数期间,哪些事件发生了。
如果clients[0].revents & POLLIN
为真,那么就表示监听套接字已经准备好,我们可以从这个套接字上接受新的连接。
这个能不能给出一个实例,不太理解
当然可以,让我们试着用一个简单的例子来理解这个概念。
首先,我们要知道POLLIN
和revents
都是整型(int)变量,也就是说,它们都可以表示为二进制数。
假设POLLIN
的值是4,也就是二进制的100
,而revents
的值是5,也就是二进制的101
。
我们用&
(位运算符AND)来对这两个值进行操作,得到的结果是100
,也就是4,非零值。
1 | POLLIN: 100 (二进制) |
这个结果是非零的,所以if (clients[0].revents & POLLIN)
的判断结果是真。这就表示revents
的POLLIN
位被设置了,也就是说,对应的文件描述符已经准备好进行读操作了。
如果revents
的值是2(二进制010
),那么结果就是零:
1 | POLLIN: 100 (二进制) |
这个结果是零,所以if (clients[0].revents & POLLIN)
的判断结果是假。这就表示revents
的POLLIN
位没有被设置,也就是说,对应的文件描述符还没有准备好进行读操作。
所以,这个表达式就是在检查revents
是否表示文件描述符已经准备好进行读操作。
recv
函数
在socket
编程中,recv
函数的返回值有以下三种情况:
当
recv
函数返回0时,表示连接已经关闭,也就是说,另一端已经执行了close
操作。当
recv
函数返回-1时,表示接收数据时发生了错误,你可以通过检查全局变量errno
来确定具体的错误类型。在非阻塞模式下,如果没有可读取的数据,recv
函数也会返回-1,并且errno
会被设置为EAGAIN
或者EWOULDBLOCK
。如果返回值大于0,表示成功接收到了数据。
在你给出的代码中,if (ret <= 0)
这个判断语句实际上就是在处理以上的前两种情况。也就是说,如果连接已经关闭,或者在接收数据时发生了错误,就关闭socket
。
在你的例子中,ret=0
实际上并不是代表LT模式下未响应此事件,而是表示对端关闭了连接。因此,关闭这个socket
是合理的,因为这个连接已经没有用了。即使接收缓冲区里还有未读取的数据,这些数据也无法被发送到已经关闭的对端,所以就没有必要保持这个socket
打开了。