网络编程
Socket
如果我们要将数据从电脑 A 的某个进程发到电脑 B 的某个进程,如果需要确保数据能够发送给对方,那就选可靠的 TCP 协议,否则可以采用 UDP 协议。
那这时候就需要用 socket 进行编程,第一步就是创建一个关于 TCP 的 socket。
下文皆以 TCP 为例
1 | // SOCK_STREAM:TCP 流套接字 |
sock_fd
相当于文件句柄,客户端和服务端都需要各自创建 fd
:
- 对于服务端:就可以根据
fd
依次执行bind()
、listen()
、accept()
方法,然后坐等客户端的连接请求 - 对于客户端:根据
fd
来执行connect()
向服务端发起建立连接的请求,此时就会发生 TCP 三次握手
以下是 TCP 三次握手的示意图:
连接建立完成后,客户端可以执行 send()
发送消息,服务端可以执行 recv()
接收消息;反过来,服务端也可以执行 send()
,客户端执行 recv()
。
相关函数:
1 | int socket(int __domain, int __type, int __protocol) |
服务端
可以使用 socket()
系统调用创建套接字,它在 <sys/socket.h>
中定义。
Definition
1 | int socket(int __domain, int __type, int __protocol) |
__domain
AF_INET
:IPv4AF_INET6
:IPv6
__type
SOCK_STREAM
:TCP 流套接字SOCK_DGRAM
:UDP 数据报套接字
__protocol
:指定协议,当__protocol
为 0 时,会自动选择type
类型对应的默认协议。IPPROTO_TCP
IPPTOTO_UDP
IPPROTO_SCTP
IPPROTO_TIPC
Usage
1 | int serverSocket = socket(AF_INET, SOCK_STREAM, 0); |
sockaddr_in
:用于存储套接字地址的数据类型htons
:该函数用于将unsigned int
从机器字节序转换为网络字节序INADDR_ANY
:当我们不想将套接字绑定到任何特定网卡 IP,而是让它监听所有网卡(所有可用 IP)时使用
1 | bind(serverSocket, (struct sockaddr*)&serverAddress, sizeof(serverAddress)); |
- 调用
bind()
绑定套接字
1 | listen(serverSocket, 5); |
- 然后监听
serverSocket
引用的套接字
1 | int clientSocket = accept(serverSocket, nullptr, nullptr); |
accept()
调用用于接受应用程序正在监听的套接字上收到的连接请求
1 | char buffer[1024] = {0}; |
- 然后开始从客户端接收数据,我们可以指定所需的缓冲区大小,以便有足够的空间接收客户端发送的数据
1 | close(serverSocket); |
- 最后使用
close()
关闭套接字
server.cpp 完整代码
1 | // C++ program to show the example of server application in |
更完整的写法:server.c
1 |
|
客户端
与服务器类似,我们也需要创建一个套接字并指定地址。不过,我们不会接受请求,而是在能够使用 connect()
调用发送数据时,发送连接请求。然后我们使用 send()
函数发送数据。所有操作完成后,我们使用 close()
函数关闭连接。
client.cpp 完整代码
1 | // C++ program to illustrate the client application in the |
更完整的写法:client.c
1 |
|
源码分析
sockaddr
sockaddr
在头文件 <sys/socket.h>
中定义,sockaddr
的缺陷是 sa_data
把「目标地址」和「端口信息」混在一起了:
1 | struct sockaddr |
sockaddr_in
sockaddr_in
在头文件 <netinet/in.h>
或 <arpa/inet.h>
中定义,该结构体解决了 sockaddr
的缺陷,把 port
和 addr
分开存储在两个变量中:
1 | struct sockaddr_in { |
1 | struct in_addr { |
sockaddr
常用于 bind
、connect
、recvfrom
、sendto
等函数的参数,指明地址信息,是一种通用的套接字地址。
sockaddr_in
是 internet
环境下套接字的地址形式。所以在网络编程中我们会对 sockaddr_in
结构体进行操作,使用 sockaddr_in
来建立所需的信息,最后使用类型转化 (struct sockaddr*)
即可。
一般先把 sockaddr_in
变量赋值后,强制类型转换后传入用 sockaddr
做参数的函数:
sockaddr_in
用于socket
定义和赋值sockaddr
用于函数参数
Usage:程序员不应该操作 sockaddr
,而是使用 sockaddr_in
来表示地址,并强转为 (struct sockaddr *)
传入函数中:
1 | // int accept(int __fd, struct sockaddr *__restrict__ __addr, socklen_t *__restrict__ __addr_len) |
1 | // int connect(int __fd, const struct sockaddr *__addr, socklen_t __len) |
讲一讲 I/O 多路复用|select、poll、epoll 的区别是什么?
I/O 多路复用是一种 I/O 的处理方式,指的是复用一个线程处理多个 socket 中的事件。能够复用资源,防止创建过多线程导致的上下文切换的开销。
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select、poll
✅ select 图解
✅ poll 图解
select
实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select
使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE
限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll
不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 $O(n)$,而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
Linux 2.6 版本诞生了 epoll 模型,彻底解决了 select/poll 性能不足的问题
✅ epoll 图解
先复习下 epoll
的用法。如下的代码中,先用 epoll_create
创建一个 epoll 对象 epoll_fd
,再通过 epoll_ctl
将需要监视的 socket 添加到 epoll_fd
中,最后调用 epoll_wait
等待数据。
1 | int s = socket(AF_INET, SOCK_STREAM, 0); |
epoll
通过两个方面,很好解决了 select/poll 的问题:
- 第一点,epoll 在内核里使用「红黑树」来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过
epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 $O(logn)$。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。 - 第二点,epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,内核通过回调函数将其加入到这个就绪事件列表中,当用户调用
epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
从下图你可以看到 epoll 相关的接口作用:
epoll
的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题(服务器同时处理 10,000个 客户端连接的挑战)的利器。