1. 阻塞IO
IO阻塞的定义与影响
IO阻塞是效率最低的程序设计模式之一。如果程序中存在多个阻塞IO操作,每次执行到阻塞IO时,程序将会停在那里,直到该IO操作完成后才继续执行。这样会导致程序的其他部分无法同时执行,造成CPU资源的浪费和处理效率的低下。常见的阻塞函数
我们在编程中已经接触过一些常见的阻塞函数,例如:accept
:在网络编程中,等待客户端连接的函数,直到接收到连接请求才返回。recvfrom
:接收数据的阻塞函数,直到有数据到达才会返回。scanf
:标准输入函数,在等待用户输入时会阻塞。getchar
:从标准输入获取一个字符,也会阻塞直到有字符输入。read
:读取文件或设备数据的阻塞函数,直到指定的字节数被读取完毕才返回。write
:写入数据时,如果缓冲区满或设备忙,则会阻塞直到可以继续写入。
阻塞函数对程序效率的影响
阻塞函数会让程序等待某些事件的发生,这些事件可能是用户输入、数据接收或其他IO操作。如果这些事件迟迟没有发生,程序便会一直停滞在阻塞状态,无法继续执行后续代码,导致CPU资源的浪费,整体执行效率变低。因此,程序的响应速度和资源利用率都受到影响。示例代码
以下是一个展示阻塞行为的简单代码示例:
#include <stdio.h>
int main() {
int n;
printf("请输入一个数字:");
// 此处阻塞,直到用户输入完成并按下回车
scanf("%d", &n);
printf("你输入的数字是:%d\n", n);
return 0;
}
进一步优化理解
阻塞 IO 的低效表现主要是因为其会等待操作完成,而不允许程序并发执行其他任务。为了解决这一问题,可以采用以下优化方式:
- 非阻塞 IO 模型:允许程序继续执行,不会因为 IO 操作被阻塞。
- 多线程或多进程:通过并发处理不同的 IO 请求,提高 CPU 利用率。
- IO 多路复用:使用
select
或epoll
等系统调用,同时监视多个文件描述符,以减少资源浪费。
2. 非阻塞IO
fcntl
函数
fcntl
是一个在文件描述符上进行设置或获取操作的系统调用。它用于获取或设置文件描述符的状态属性,包括文件的阻塞模式、访问模式等。
函数原型
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
参数说明:
fd
: 文件描述符,指定操作的文件或设备。cmd
: 指定操作类型,常用的有:F_GETFL
:获取文件描述符的状态。F_SETFL
:设置文件描述符的状态。
arg
: 可选参数,具体取决于cmd
的值:- 如果
cmd
是F_GETFL
,则该参数可省略。 - 如果
cmd
是F_SETFL
,则arg
可以是如下之一(它们通常通过位或运算组合):O_RDONLY
:设置为只读状态。O_WRONLY
:设置为只写状态。O_RDWR
:设置为读写状态。O_NONBLOCK
:设置为非阻塞状态。
- 如果
返回值
- 如果
cmd
是F_GETFL
,成功返回文件描述符的状态值,失败返回-1
并设置错误码。并设置错误码。 - 如果
cmd
是F_SETFL
,成功时返回0,失败时返回-1,并设置错误码。
2. 示例代码
以下是使用fcntl
设置文件描述符为非阻塞状态的示例:
#include <myhead.h>
int main(int argc, const char *argv[])
{
int n;
// 获取标准输入的文件描述符状态
int arg = fcntl(0, F_GETFL, 0); // 0是标准输入
printf("标准输入的状态值: %d\n", arg);
// 将标准输入的文件描述符设置为非阻塞状态
fcntl(0, F_SETFL, arg | O_NONBLOCK);
while (1)
{
scanf("%d", &n); // 非阻塞模式下,scanf不会阻塞
printf("n = %d\n", n);
}
return 0;
}
代码解释:
- 通过
fcntl(0, F_GETFL, 0)
获取标准输入(文件描述符0)的当前状态值。 - 然后使用
fcntl(0, F_SETFL, arg | O_NONBLOCK)
将标准输入的状态设置为非阻塞状态。 - 在
while
循环中,scanf
不会阻塞程序执行,如果没有输入,它会立即继续执行而不会等待用户输入。
- 通过
总结:
fcntl
函数用于控制文件描述符的状态,可以将文件描述符设置为非阻塞模式。通过合理使用非阻塞IO,可以避免程序因等待IO操作而停滞,提高程序的响应速度和并发处理能力。
2.3 实现非阻塞输入的完整示例
代码
#include "/home/ubuntu/myheader.h" // 替换为实际头文件路径
int main(int argc, const char *argv[]) {
int n;
// 获取标准输入描述符状态
int arg = fcntl(0, F_GETFL, 0);
printf("标准输入的初始状态值: %d\n", arg);
// 设置标准输入为非阻塞状态
fcntl(0, F_SETFL, arg | O_NONBLOCK);
// 不断读取用户输入
while (1) {
if (scanf("%d", &n) > 0) { // 非阻塞模式下,scanf 不会等待输入
printf("n = %d\n", n);
} else {
printf("未检测到输入,继续尝试...\n");
}
}
return 0;
}
说明
- 程序先获取标准输入的当前状态值。
- 使用
fcntl
函数将标准输入设置为非阻塞模式。 - 非阻塞模式下,
scanf
不会一直等待输入,能有效避免程序阻塞。
2.4 非阻塞 IO 的优势与使用场景
优势
- 避免阻塞造成的资源浪费。
- 在单线程中实现更高效的任务处理。
使用场景
- 网络编程中对大量客户端请求的并发处理。
- 实现流式数据读取时减少程序停顿。
- 提高实时性要求的程序性能。
3. IO 多路复用
多路复用简介
没有操作系统的情景:
如果计算机没有操作系统,就没有进程和线程的概念,无法通过操作系统提供的多任务并发功能。在这种情况下,程序需要自己实现多任务并发。IO多路复用的作用:
IO多路复用可以让单个进程在多个IO操作上进行“并发”,通过一个程序实现类似多任务处理的效果。即便是在没有操作系统的环境下,程序依然可以模拟并发执行多个IO操作。原理:
IO多路复用的实现原理是将所有阻塞的IO操作放入一个集合中,然后通过某种机制监视这些IO操作。如果其中某个或多个IO操作发生了事件(如数据准备好读取或写入),程序会解除这些IO操作的阻塞状态,允许继续处理。其他没有发生事件的IO操作仍然处于阻塞状态。示意图:
通常,IO多路复用的原理图会展示一个进程如何同时监控多个文件描述符(或IO流),并在一个或多个IO事件发生时解除阻塞,避免一个IO操作阻塞整个程序。
select
函数介绍
函数原型:
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
功能:
select
函数用于监视多个文件描述符,判断它们是否有读、写或错误事件发生。当一个或多个文件描述符的状态发生变化时,select
会解除它们的阻塞。通过这种方式,程序可以在一个线程中并发处理多个IO操作。参数:
nfds
:最大文件描述符+1,表示要监视的文件描述符的个数。readfds
:用于监视可读事件的文件描述符集合。writefds
:用于监视可写事件的文件描述符集合。exceptfds
:用于监视异常事件的文件描述符集合。timeout
:指定超时时间,超过指定时间会自动解除阻塞。
timeout
的结构体定义:struct timeval { long tv_sec; /* 秒 */ long tv_usec; /* 微秒 */ };
返回值:
- 成功时,返回解除阻塞的描述符个数。
- 如果发生错误,返回-1,并设置
errno
。
常用宏函数:
FD_CLR(int fd, fd_set *set)
:从集合中清除指定的描述符。FD_ISSET(int fd, fd_set *set)
:判断指定描述符是否在集合中。FD_SET(int fd, fd_set *set)
:将指定描述符添加到集合中。FD_ZERO(fd_set *set)
:清空集合中的所有描述符。
4. 示例:使用select
解除阻塞
以下是使用select
函数来验证阻塞解除的示例代码:
#include <myhead.h>
int main(int argc, const char *argv[])
{
int n;
fd_set readfds; // 定义一个集合
FD_ZERO(&readfds); // 清空整个集合
FD_SET(0, &readfds); // 将标准输入描述符(0)放入集合中,进行监视
struct timeval time = {5, 0}; // 设置5秒的阻塞时间
int res = select(0 + 1, &readfds, NULL, NULL, &time);
scanf("%d", &n); // 发生写操作
if (FD_ISSET(0, &readfds)) {
printf("输入事件发生了,解除阻塞\n");
}
if (res == 0) {
printf("超时\n");
}
return 0;
}
代码解释:
- 使用
FD_ZERO
清空文件描述符集合,使用FD_SET
将标准输入(文件描述符 0)添加到集合中。 select
监视标准输入的读操作,超时时间设为 5 秒。如果在 5 秒内有输入,select
会解除阻塞。- 如果
FD_ISSET(0, &readfds)
为真,说明标准输入发生了读事件,解除阻塞,程序继续执行。
5. IO多路复用的优势
- 提高效率:通过避免在多个阻塞操作之间进行切换,IO多路复用可以使得程序更加高效地处理多个IO操作,减少了系统开销。
- 简化线程管理:IO多路复用可以在单个线程内处理多个IO操作,而不需要创建和管理多个线程,从而降低了并发操作的复杂性。
补充说明:
select
适用于文件描述符数量较小的情况,因为它的性能受限于fd_set
集合的大小,通常最大为1024个文件描述符。如果需要处理更多的文件描述符,可能需要考虑使用poll
或者epoll
等其他方式。
4. 使用IO多路复用完成TCP并发服务器
在基于IO多路复用的TCP并发服务器模型中,我们通过使用select
等IO多路复用技术,同时管理多个客户端连接并处理它们的请求。服务器使用一个大的文件描述符集合来监视多个客户端的请求,能够高效地处理大量并发连接。下面是这一模型的实现思路和伪代码:
模型描述
- 定义两个容器:用于存储活动的文件描述符。
- 清空容器:在每次循环开始时,清空上一次的状态。
- 旧的套接字放入容器:将原始的监听套接字放入集合中。
- 记录最大文件描述符
maxfd
,用于确定select
的参数。
伪代码实现
#include <myhead.h>
int main(int argc, const char *argv[]) {
int listenfd, connfd, maxfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
fd_set readfds, tempfds;
int client_fds[FD_SETSIZE]; // 客户端连接文件描述符集合
int i, n;
// 创建监听套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1) {
perror("socket");
return -1;
}
// 初始化服务器地址结构体
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8080);
// 绑定套接字
if (bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
return -1;
}
// 开始监听
if (listen(listenfd, 10) == -1) {
perror("listen");
return -1;
}
// 初始化文件描述符集合
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds); // 将监听套接字加入集合
maxfd = listenfd; // 初始时最大描述符是监听套接字
// 客户端连接文件描述符集合初始化
for (i = 0; i < FD_SETSIZE; i++) {
client_fds[i] = -1;
}
while (1) {
// 复制一份readfds容器,用于select阻塞
tempfds = readfds;
// 使用select阻塞,等待某些描述符准备好
n = select(maxfd + 1, &tempfds, NULL, NULL, NULL);
if (n == -1) {
perror("select");
return -1;
}
// 遍历所有文件描述符,处理事件
for (i = 0; i <= maxfd; i++) {
if (FD_ISSET(i, &tempfds)) { // 如果描述符i准备好了
if (i == listenfd) { // 如果是监听套接字准备好,接受新连接
client_len = sizeof(client_addr);
connfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_len);
if (connfd == -1) {
perror("accept");
continue;
}
// 将新连接的描述符加入集合
FD_SET(connfd, &readfds);
client_fds[connfd] = connfd;
// 更新最大描述符
maxfd = (connfd > maxfd) ? connfd : maxfd;
printf("New connection from %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
} else { // 如果是客户端描述符准备好了,接收数据
char buffer[1024];
int len = recv(i, buffer, sizeof(buffer), 0);
if (len == 0) { // 客户端关闭连接
close(i); // 关闭客户端连接
FD_CLR(i, &readfds); // 从集合中移除
client_fds[i] = -1; // 清空客户端描述符
if (i == maxfd) { // 如果关闭的是最大描述符,更新最大描述符
while (FD_ISSET(maxfd, &readfds) == 0) {
maxfd--;
}
}
printf("Client disconnected\n");
} else if (len > 0) {
buffer[len] = '\0';
printf("Received: %s\n", buffer);
// 回复客户端数据
send(i, buffer, len, 0);
}
}
}
}
}
close(listenfd);
return 0;
}
代码解释:
初始化:
- 使用
socket()
函数创建监听套接字。 - 使用
bind()
绑定服务器地址和端口。 - 调用
listen()
开启监听。
- 使用
文件描述符集合:
FD_SET()
将监听套接字加入readfds
集合。- 使用
select()
阻塞,等待readfds
中的文件描述符准备好。
处理连接:
- 如果
select()
返回的描述符是监听套接字(listenfd
),说明有新连接请求,使用accept()
接受连接,并将新连接的文件描述符加入readfds
集合。 - 如果
select()
返回的是某个客户端的连接,使用recv()
接收数据,并根据接收到的数据决定是继续接收还是关闭连接。
- 如果
客户端连接管理:
- 每个连接的客户端都维护在
readfds
集合中,如果客户端关闭连接,移除相应的文件描述符。 maxfd
用于动态更新当前最大的文件描述符,确保select()
调用时能够覆盖所有有效的文件描述符。
- 每个连接的客户端都维护在
IO多路复用实现并发服务器
该程序展示了如何使用IO多路复用技术(select
)来实现一个能够同时处理多个客户端连接的并发服务器。程序使用了TCP协议,并通过select()
来监视多个文件描述符,从而处理多个客户端的请求。
代码解析:
#include <myhead.h>
#define IP "192.168.60.59"
#define PORT 6666
#define BACKLOG 20
int main(int argc, const char *argv[])
{
// 1. 创建套接字
int oldfd = socket(AF_INET, SOCK_STREAM, 0); // AF_INET: IPV4通信;SOCK_STREAM: TCP协议
if (oldfd == -1)
{
perror("socket");
return -1;
}
// 2. 绑定IP和端口号
struct sockaddr_in server = {
.sin_family = AF_INET, // IPV4
.sin_port = htons(PORT), // 端口号转为网络字节序
.sin_addr.s_addr = inet_addr(IP), // IP地址
};
if (bind(oldfd, (struct sockaddr *)&server, sizeof(server)) == -1)
{
perror("bind");
return -1;
}
// 3. 监听
if (listen(oldfd, BACKLOG) == -1)
{
perror("listen");
return -1;
}
// 4. 初始化客户端信息结构体和容器
struct sockaddr_in client; // 客户端信息结构体
socklen_t client_len = sizeof(client); // 计算出结构体大小
fd_set readfds, temp; // 定义两个集合存放描述符
FD_ZERO(&readfds); // 清空集合内的内容
FD_SET(0, &readfds); // 添加标准输入(0号描述符)
FD_SET(oldfd, &readfds); // 添加监听套接字描述符
int maxfd = oldfd; // 初始化最大描述符
int newfd;
// 5. 循环接收多个客户端连接
while (1)
{
// 5-1 监视所有描述符
temp = readfds;
int res = select(maxfd + 1, &temp, NULL, NULL, NULL); // 永久阻塞
if (res == -1)
{
perror("select");
return -1;
}
if (res == 0)
{
printf("超时\n");
continue;
}
// 程序执行到此,说明某些描述符解除了阻塞
// 5-2 循环检测发生IO操作的描述符
for (int i = 0; i <= maxfd; i++)
{
if (!FD_ISSET(i, &temp)) // 如果描述符没有解除阻塞,继续下次循环
{
continue;
}
if (i == oldfd) // 如果是监听套接字解除阻塞
{
// 接受新客户端连接并创建新的描述符
newfd = accept(i, (struct sockaddr *)&client, &client_len);
FD_SET(newfd, &readfds); // 将新描述符加入集合
maxfd = (newfd > maxfd) ? newfd : maxfd; // 更新最大描述符
if (newfd == -1)
{
perror("accept");
return -1;
}
printf("%s发来连接请求\n", inet_ntoa(client.sin_addr));
}
else // 如果是客户端描述符解除阻塞,进行数据收发
{
// 5-3 循环收发信息
char buff[1024];
memset(buff, 0, sizeof(buff));
int len = recv(i, buff, sizeof(buff), 0); // 阻塞接收
if (len == 0) // 如果客户端关闭连接
{
close(i); // 关闭客户端连接
FD_CLR(i, &readfds); // 从集合中移除
if (maxfd == i) // 如果关闭的是最大描述符,更新最大描述符
{
maxfd--;
}
printf("客户端下线\n");
break;
}
printf("%s\n", buff);
fgets(buff, sizeof(buff), stdin); // 从标准输入获取信息
send(i, buff, sizeof(buff), 0); // 回复客户端
}
}
}
// 6. 关闭监听套接字
close(oldfd);
return 0;
}