开发环境:

RT-Thread版本:4.0.3

操作系统:Windows10

RT-Thread Studio版本:2.1.1

开发板MCU:STM32H750XB

LWIP:2.0.2

并发服务器支持多个客户端的同时连接,最大可接入的客户端数取决于内核控制块的个数。当使用Socket API时,要使服务器能够同时支持多个客户端的连接,必须引入多任务机制,为每个连接创建一个单独的任务来处理连接上的数据,多任务可以是多线程或者多进程,这是最常用的并发服务器设计。但是多线程/多进程消耗资源多,处理起来也比较复杂,本文将基于LWIP协议栈的Select/Poll机制实现并发服务器



9.1 IO模型概述

在具体讲解基于Select/Poll机制实现并发服务器之前,我们需要了解IO的相关概念,所谓IO就是,就是数据的读写,一般分为网络IO(本质就是socket读写)和磁盘IO

IO模型大致可以分为:同步阻塞、同步非阻塞、异步、信号驱动

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_协议栈

可细分为5种I/O模型:


1)阻塞I/O,进程处于阻塞模式时,让出CPU,进入休眠状态;
2)非阻塞I/O,非阻塞模式的使用并不普遍,因为非阻塞模式会浪费大量的CPU资源;
3)I/O复用(select和poll),针对批量IP操作时,使用I/O多路复用,非常有好;
4)异步I/O(POSIX的aio_系列函数)
5)信号驱动I/O(SIGIO)


一个输入操作通常包括两个不同的阶段:


1)等待数据准备好;
2)从内核向进程复制数据;


对于一个套接字的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

9.1.1阻塞I/O

阻塞 I/O 模式是最普遍使用的 I/O 模式。一个套接字建立后所处于的模式就是阻塞 I/O 模式。(因为Linux系统默认的IO模式是阻塞模式)。对于一个 UDP 套接字来说,数据就绪的标志比较简单:


(1)已经收到了一整个数据报
(2)没有收到。


而 TCP 这个概念就比较复杂,需要附加一些其他的变量。

最流行的I/O模型是阻塞式I/O(blocking I/O) 模型,默认情况下,所有的套接字都是阻塞的。阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,CPU不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。以数据包套接字为例,如图。

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_数据_02

进程调用recvfrom,其系统调用直到数据报到达且被拷贝到应用进程的缓冲区或者发生错误才返回。最常见的错误是系统调用被信号中断。我们说进程从调用recvfrom开始到它返回的整段时间内是被阻塞的,recvfrom成功返回后,进程开始处理数据报。

9.1.2非阻塞I/O

进程把一个套接口设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_数据_03

前三次调用recvfrom 时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK 错误。第四次调用 recvfrom 时已有一个数据报准备好,它被复制到应用程序缓冲区,于是recvfrom 成功返回。我们接着处理数据。

当一个应用程序使用了非阻塞模式的套接字,它需要使用一个循环来不听的测试是否一个文件描述符有数据可读(称做 polling(轮询))。应用程序不停的 polling 内核来检查是否 I/O操作已经就绪。这将是一个极浪费 CPU资源的操作。这种模式使用中不是很普遍。

非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

9.1.3 I/O复用

在使用 I/O 多路技术的时候,我们调用 select()函数和 poll()函数或epoll函数(2.6内核开始支持),在调用它们的时候阻塞,而不是我们来调用 recvfrom(或recv)的时候阻塞。主要可以调用select和epoll;对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听,可以等待多个描述符就绪。

I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

当我们调用 select函数阻塞的时候,select 函数等待数据报套接字进入读就绪状态。当select函数返回的时候, 也就是套接字可以读取数据的时候。 这时候我们就可以调用 recvfrom函数来将数据拷贝到我们的程序缓冲区中。

对于单个I/O操作,和阻塞模式相比较,select()和poll()或epoll并没有什么高级的地方。而且,在阻塞模式下只需要调用一个函数:读取或发送函数。在使用了多路复用技术后,我们需要调用两个函数了:先调用 select()函数或poll()函数,然后才能进行真正的读写。

多路复用的高级之处在于:它能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_io_04

IO 多路技术一般在下面这些情况中被使用:

  • 当一个客户端需要同时处理多个文件描述符的输入输出操作的时候(一般来说是标准的输入输出和网络套接字),I/O 多路复用技术将会有机会得到使用。
  • 当程序需要同时进行多个套接字的操作的时候。
  • 如果一个 TCP 服务器程序同时处理正在侦听网络连接的套接字和已经连接好的套接字。
  • 如果一个服务器程序同时使用 TCP 和 UDP 协议。
  • 如果一个服务器同时使用多种服务并且每种服务可能使用不同的协议(比如 inetd就是这样的)。

9.1.4异步I/O模型

异步I/O(asynchronous I/O)有POSIX规范定义。后来演变成当前POSIX规范的各种早期标准定义的实时函数中存在的差异已经取得一致。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到我们自己的缓冲区)完成后通知我们。这种模型与前与前面介绍的信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_协议栈_05

9.1.5信号驱动I/O模型

我们也可以用信号,让内核在描述字就绪时发送SIGIO信号通知我们。我们称这种模型为信号驱动I/O(signal-driven I/O)。

我们首先开启套接口的信号驱动I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用立即发回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已经准备好待处理,也可以立即通知主循环,让它读取数据报。

无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间,进程不被阻塞。主循环可以继续执行,只要不时等待来自信号处理函数的通知:既可以是数据已经准备好被处理,也可以是数据报已准备好被读取。

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_数据_06

9.1.6各种模型的比较

各种模型的比较如下图所示,可以看出,前4种模型的主要区别在于第一阶段,因为它们的第二阶段是一样的:在数据从内核复制到调用者的缓冲区起见,进程阻塞与recvfrom 调用,相反。异步I/O模型在这两个阶段都需要处理,从而不同于其他四种模型。

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_rt-thread_07

同步I/O与异步I/O对比

POSIX把这两个术语定义如下:

  • 同步I/O操作(synchronous I/O operation)导致请求进程阻塞,直到I/O操作完成。
  • 异步I/O(asynchronous I/O operation)不导致请求进程阻塞。

根据上述定义,我们前4种模型----阻塞I/O模型、非阻塞I/O模型、I/O复用模型和信号去驱动I/O模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O相匹配

本文的要将的I/O复用,本质就是select/poll机制。因此,其他IO有兴趣可以去了解。





9.2 RT-thread的网络架构

RT-Thread 的网络框架结构如下所示:

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_select_08

最顶层是网络应用层,提供一套标准 BSD Socket API ,如 socket、connect 等函数,用于系统中大部分网络开发应用, BSD Socket API已经是网络套接字的事实上的抽象标准。使用BSD Socket API编写应用,不会依赖具体的操作系统,但是底层的具体实现是依赖操作系统的。

第二部分为 SAL 套接字抽象层,通过它 RT-Thread 系统能够适配下层不同的网络协议栈,并提供给上层统一的网络编程接口,方便不同协议栈的接入。套接字抽象层为上层应用层提供接口有:accept、connect、send、recv 等。

第三部分为 netdev 网卡层,主要作用是解决多网卡情况设备网络连接和网络管理相关问题,通过 netdev 网卡层用户可以统一管理各个网卡信息和网络连接状态,并且可以使用统一的网卡调试命令接口。

第四部分为协议栈层,该层包括几种常用的 TCP/IP 协议栈,例如嵌入式开发中常用的轻型 TCP/IP 协议栈 lwIP 以及 RT-Thread 自主研发的 AT Socket 网络功能实现等。这些协议栈或网络功能实现直接和硬件接触,完成数据从网络层到传输层的转化。

最后一层为硬件层,ETH是唯一的有线网络接入方式,其余都是无线网络接入方式,LTE模组,Cat模组,NB-IOT模组这些依赖基站运营商的入网方式,例如 SIM800,EC25,AIR720,L610,N58,M5311 等,这些不同厂家,不同工作频率的模组均可以通过 NET 组件入网;WIFI 这种无需运营商直接提供的网络的入网方式,例如 ESP8266,W60x,AP6212,rw007等。

RT-Thread 的网络应用层提供的接口主要以标准 BSD Socket API 为主,这样能确保程序可以在 Windows或者Linux上编写、调试,然后再移植到 RT-Thread 操作系统上。

RT-Thread对于不同的协议栈或网络功能实现,网络接口的名称可能各不相同,以 connect 连接函数为例,lwIP 协议栈中接口名称为 lwip_connect ,而 AT Socket 网络实现中接口名称为 at_connect。SAL 组件提供对不同协议栈或网络实现接口的抽象和统一,组件在 socket 创建时通过判断传入的协议簇(domain)类型来判断使用的协议栈或网络功能,完成 RT-Thread 系统中多协议的接入与使用。

目前 SAL 组件支持的协议栈或网络实现类型有:lwIP 协议栈、AT Socket 协议栈、WIZnet 硬件 TCP/IP 协议栈。

int socket(int domain, int type, int protocol);

上述为标准 BSD Socket API 中 socket 创建函数的定义,domain 表示协议域又称为协议簇(family),用于判断使用哪种协议栈或网络实现,AT Socket 协议栈使用的簇类型为 AF_AT,lwIP 协议栈使用协议簇类型有 AF_INET等,WIZnet 协议栈使用的协议簇类型为 AF_WIZ

对于不同的软件包,socket 传入的协议簇类型可能是固定的,不会随着 SAL 组件接入方式的不同而改变。为了动态适配不同协议栈或网络实现的接入,SAL 组件中对于每个协议栈或者网络实现提供两种协议簇类型匹配方式:主协议簇类型和次协议簇类型。socket 创建时先判断传入协议簇类型是否存在已经支持的主协议类型,如果是则使用对应协议栈或网络实现,如果不是判断次协议簇类型是否支持。目前系统支持协议簇类型如下:

  • lwIP 协议栈: family = AF_INET、sec_family = AF_INET
  • AT Socket 协议栈: family = AF_AT、sec_family = AF_INET
  • WIZnet 硬件 TCP/IP 协议栈: family = AF_WIZ、sec_family = AF_INET

SAL 组件主要作用是统一抽象底层 BSD Socket API 接口,下面以 bind函数调用流程为例说明 SAL 组件函数调用方式:

  • bind:SAL 组件对外提供的抽象的 BSD Socket API,用于统一 fd 管理;
  • sal_bind:SAL 组件中 bind实现函数,用于指定端口和网卡(当存在多个网卡的时候)。
  • lwip_bind:底层协议栈提供的层 bind连接函数,在网卡初始化完成时注册到 SAL 组件中,最终调用的操作函数。
/* SAL 组件为应用层提供的标准 BSD Socket API */
int bind(int s, const struct sockaddr *name, socklen_t namelen)
{
/* 获取 SAL 套接字描述符 */
int socket = dfs_net_getsocket(s);

/* 通过 SAL 套接字描述符执行 sal_bind 函数 */
return sal_bind(socket, name, namelen);
}
/* SAL 组件抽象函数接口实现 */
int sal_bind(int socket, const struct sockaddr *name, socklen_t namelen)
{
struct sal_socket *sock;
struct sal_proto_family *pf;
ip_addr_t input_ipaddr;

RT_ASSERT(name);

/* get the socket object by socket descriptor */
SAL_SOCKET_OBJ_GET(sock, socket);

/* bind network interface by ip address */
sal_sockaddr_to_ipaddr(name, &input_ipaddr);

/* check input ipaddr is default netdev ipaddr */
if (!ip_addr_isany_val(input_ipaddr))
{
struct sal_proto_family *input_pf = RT_NULL, *local_pf = RT_NULL;
struct netdev *new_netdev = RT_NULL;

new_netdev = netdev_get_by_ipaddr(&input_ipaddr);
if (new_netdev == RT_NULL)
{
return -1;
}

/* get input and local ip address proto_family */
SAL_NETDEV_SOCKETOPS_VALID(sock->netdev, local_pf, bind);
SAL_NETDEV_SOCKETOPS_VALID(new_netdev, input_pf, bind);

/* check the network interface protocol family type */
if (input_pf->family != local_pf->family)
{
int new_socket = -1;

/* protocol family is different, close old socket and create new socket by input ip address */
local_pf->skt_ops->closesocket(socket);

new_socket = input_pf->skt_ops->socket(input_pf->family, sock->type, sock->protocol);
if (new_socket < 0)
{
return -1;
}
sock->netdev = new_netdev;
sock->user_data = (void *) new_socket;
}
}

/* check and get protocol families by the network interface device */
SAL_NETDEV_SOCKETOPS_VALID(sock->netdev, pf, bind);
return pf->skt_ops->bind((int) sock->user_data, name, namelen);
}
/* lwIP 协议栈函数底层 bind 函数实现 */
int lwip_bind(int s, const struct sockaddr *name, socklen_t namelen)
{
struct lwip_sock *sock;
ip_addr_t local_addr;
u16_t local_port;
err_t err;

sock = get_socket(s);
if (!sock) {
return -1;
}

if (!SOCK_ADDR_TYPE_MATCH(name, sock)) {
/* sockaddr does not match socket type (IPv4/IPv6) */
sock_set_errno(sock, err_to_errno(ERR_VAL));
return -1;
}

/* check size, family and alignment of 'name' */
LWIP_ERROR("lwip_bind: invalid address", (IS_SOCK_ADDR_LEN_VALID(namelen) &&
IS_SOCK_ADDR_TYPE_VALID(name) && IS_SOCK_ADDR_ALIGNED(name)),
sock_set_errno(sock, err_to_errno(ERR_ARG)); return -1;);
LWIP_UNUSED_ARG(namelen);

SOCKADDR_TO_IPADDR_PORT(name, &local_addr, local_port);
LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_bind(%d, addr=", s));
ip_addr_debug_print_val(SOCKETS_DEBUG, local_addr);
LWIP_DEBUGF(SOCKETS_DEBUG, (" port=%"U16_F")\n", local_port));

#if LWIP_IPV4 && LWIP_IPV6
/* Dual-stack: Unmap IPv4 mapped IPv6 addresses */
if (IP_IS_V6_VAL(local_addr) && ip6_addr_isipv4mappedipv6(ip_2_ip6(&local_addr))) {
unmap_ipv4_mapped_ipv6(ip_2_ip4(&local_addr), ip_2_ip6(&local_addr));
IP_SET_TYPE_VAL(local_addr, IPADDR_TYPE_V4);
}
#endif /* LWIP_IPV4 && LWIP_IPV6 */

err = netconn_bind(sock->conn, &local_addr, local_port);

if (err != ERR_OK) {
LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_bind(%d) failed, err=%d\n", s, err));
sock_set_errno(sock, err_to_errno(err));
return -1;
}

LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_bind(%d) succeeded\n", s));
sock_set_errno(sock, 0);
return 0;
}

ART-Pi有两种常用的联网方式,一个是板载的WiFi模块AP6212,这个模块自带蓝牙;另一个是工业扩展板的网口,使用的芯片是LAN8720A。关于多网卡的使用和自动切换在前面的章节有所讲解。本文主要讲解如何使用Select/Poll机制来实现并发服务器

​RT-Thread网络组件​





欢迎访问我的网站

​BruceOu的哔哩哔哩​

​BruceOu的主页​

​BruceOu的博客​

​BruceOu的简书​


欢迎订阅我的微信公众号

《嵌入式系统 – 玩转ART-Pi开发板》第9章 基于Select/Poll实现并发服务器(一)_io_09