前面我们讲解了基础的IO知识,掌握好他们后,对于我们理解高级IO有非常大的帮助
谁在IO呢?
在用户视角看,我们更关注进程或线程
在系统角度看,IO不仅用户去进行,可能会由OS去承担
注:
CPU不和外设直接打交道指的是数据层面上的,有些控制信号是可以由外设达到CPU)
所以当外设上有数据时,外设会向我们CPU发送硬件中断(光电信号);CPU立马识别到,根据中断号执行网卡驱动中读数据的方法。
(中断 包含 中断号;每一个中断号对应一个处理方法。由OS执行,网卡驱动提供)
总:
网卡读到的数据,本质上是通过中断的方式告诉CPU,CPU识别到对应的针脚(读取到编号),根据中断相关的处理函数来进行数据读取,将数据从网卡->内存
是的
OS要不要将所有收到的报文管理起来呢?
需要的,先描述,再组织
(先用结构体描述,再用数据结构组织起来)
如:
//大概的结构如下,当然底层结构复杂的多,此时只是为了方便理解
struct sk_buffer{
char* mac_header;
char* net_header;
char* tcp_header;
char buffer[1024];
struct sk_buffer *next;
struct sk_buffer *prev;
......
}
//其是一个双链表结构
解析时,先让buffer中的mac_header指向头部,然后开始提取;与有效载荷分离时,让net_header指向IP报头开始,这样通过指针的操作,就能分别提取出各个报头。
向上交付时,报文所在缓冲区就没变,只是在更改指针操作,将我们对应不同阶段的报头用指针指向特别的区域,然后进行数据分析
当我们识别完 tcp 后,将剩下一部分数据拷贝至网络的接收缓存区,我们即收到了
进行IO的过程分两步进行:
读时要接收缓存区中有数据,读事件就绪。
写时要发送缓冲区有空间,这才能拷贝,写事件就绪。
拷贝
在特定时间段内,大大减小等的比重,增加拷贝的比重,即为高效的 IO
钓鱼,大家应该都知道,钓鱼分为几步呢?和 IO 一样,为等+钓,那现在以下有这几人在钓鱼,钓鱼的方式各有不同。
我们现在来区分一下谁钓鱼的效率最高。
由上可知,A、B、C三人钓鱼效率是一样的。因为钓鱼的方式是一样的,只是等的方式不一样。
D的效率更高,因为其将等待的时间重叠,同时等待100个鱼竿,钓的比重增高了
总:
阻塞IO、非阻塞IO、信号驱动IO并不能提高IO的效率。但非阻塞IO、信号驱动IO可提高我们做事的效率
总:
这里的钓鱼过程可看作一个IO过程
在内核将数据准备好之前,系统调用会一直等待。
所有的套接字默认都是阻塞的方式。
阻塞IO是最常见的IO模型。
当实际在进行 IO 时,要从套接字上读数据,可是数据可能还没有来;对方还没有给你发送,数据可能还在网络中。所以必须由OS从网络中把数据读取上来放进接收缓存区。
这里的阻塞本质上是将进程的状态设为S,非R状态,然后将该进程放入等待队列中
当数据准备好,OS会把你从等待队列中唤醒,执行recvfrom
如果内核还未将数据准备好,系统调用依然会直接返回,并且返回EWOULDBLOCK错误码
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。
所谓的阻塞(OS发起,OS执行),是用户层的感受。在内核中本质是进程被挂起(S or T or D)。需要等待某种事件就绪。
所谓的非阻塞(由用户发起,OS执行)轮询的本质:在做事件就绪的检测工作
内核将数据准备好的时候,使用 SIGIO 的信号通知应用程序进行 IO 操作
本质是 同步 IO 的一种,因为 IO 的过程是同步的。(当数据就绪时,要自己把数据从内核拷贝到用户)
由内核在数据拷贝完成时,通知应用程序
异步 IO 在大部分编程中用的也比较少,不过在一些场景中,也有可能被使用
异步 IO 实际在调用时需要你去调用一下 异步 IO接口,同时,还需要你提供一个用户缓冲区
虽然从流程图上看起来和 阻塞IO 类似。实际上最核心在于 IO多路转接 能够同时等待多个文件描述符的就绪状态
IO 分为等和拷贝,所以 recv、read、write、send 除了进行拷贝以外,还要进行等待
而 select、poll、epoll为多路转接的函数(只等)
再用recv、read、write、send进行拷贝,此时recv、read、write、send(只能传入一个文件描述符)只关注拷贝
所以靠上面多路转接的函数与recv、read、write、send即可完成多路转接。
任何 IO 过程中,都包含两个步骤:第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的方法就是让等待的时间尽量少
==我们现在用的最多的是:阻塞、非阻塞、多路转接
阻塞和非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态
同步和异步关注的是消息通信机制
另外,我们回忆在讲多进程多线程的时候,也提到同步和互斥,这里的同步通信和进程之间的同步是完全不相干的概念。
非阻塞IO、记录锁、系统V流机制、I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射IO(mmp),这些统称为高级IO
我们重点关注IO多路转接
在open中可设置O_NONBLOCK,让文件打开时以非阻塞方式打开
#include
#include
#include
int open(const char* pathname,int flag);
int open(const char* pathname,int flag,mode_t mode);
将一个已经打开的文件设为非阻塞
文件打开默认进行的都是阻塞IO
#include
#include
int fcntl(int fd,int cmd, ... /* arg */);
fcntl可以设置非阻塞,不代表它只能设置非阻塞
其传入的cmd值不同,后面追加的参数也不相同
fcntl函数有5种功能
复制一个现有的描述符(cmd=F_DUPFD)
获得/设置文件描述符状态(cmd=F_GETED或F_SETFD)
获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)
获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)
我们只用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞
基于fcntl,我们实现一个SetNoBlack函数,将文件秒速符设为非阻塞
void SetNoBlock(int fd){
int fl = fcntl(fd,F_GETFL);
if(fl < 0){
perror("fcntl");
return;
}
fcntl(fd,F_SETFL,fl | O_NONBLOCK);
}
使用F_GETFL将当前的文件描述符的属性取出
然后再F_SETFL将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOCK参数
#include
#include
#include
#define NUM 1024
int main(){
while(1){
char buffer[1024];
ssize_t size = read(0,buffer,sizeof(buffer)-1);
if(size < 0){
std::cerr << "read error" << size << std::endl;
break;
}
buffer[size] = 0;
std::cout << "echp#" << buffer << std::endl;
}
}
#include
#include
#include
#define NUM 1024
bool SetNoBlock(int fd){
int fl = fcntl(fd,F_GETFL);
if(fl < 0){
std::cerr << "fcntl error" << std::endl;
return false;
}
fcntl(fd,F_SETFL,fl | O_NONBLOCK);
return true;
}
int main(){
SetNoBlock(0);
while(true){
char buffer[1024];
ssize_t size = read(0,buffer,sizeof(buffer)-1);
if(size < 0){
if(errno == EAGAIN || errno == EWOULDBLOCK){
std::cout << "底层的数据没有准备就绪,再轮询检测一下" << std::endl;
sleep(1);
continue;
}
if(errno == EINTR){
std::cout << "底层的数据就绪未知,被信号打断" << std::endl;
continue;
}
else{
std::cerr << "read error" << std::endl;
break;
}
}
buffer[size] = 0;
std::cout << buffer << std::endl;
}
}
这里面种有几点需要解释一下:
select本质是一种就绪事件的通知机制
它的核心工作是等,一旦事件就绪即通知上层
read,底层数据从无到有,从有到多,读事件就绪
write,底层缓冲区剩余空间从无到有,从有到多,即写事件就绪
底层只要有数据,底层缓冲区只要有空间,都叫做select的读事件和写事件就绪。
然后再去调用read、recv、write、send等不会被阻塞
select可以一次等待多个文件描述符
#include
int select(int nfds,fd_set* readfds,fd_set* writefds,fd_set* exceptfds,struct timeval* timeout);
nfds:select在等待的多个文件描述符值中,最大的文件描述符+1
所有的fd_set*类型为输入输出型参数
从第二个开始,分别为读事件、写事件、异常事件的集合
timeout:设置select等待时间,传NULL即让select一直等,直到有一个就绪再返回。传0,现在去等,然后马上返回
select不进行读写,读写时recv、read的工作
select要对多个文件描述符进行轮询检测,告诉我要等的范围,我会对整个范围轮询检测
fd_set类似sigset_t,是一个位图,可以将特定的fd添加到位图中;使用位图中对应的位来表示要监视的文件描述符
所以提供了一组操作fd_set的接口,来比较方便的操作位图
void FD_CLR(int fd,fd_set* set); //用来清除描述词组set中相关fd的位
void FD_ISSET(int fd,fd_set* set); //用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd,fd_set* set); //用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set* set); //用来清除描述词组set的全部位
用户调用select,user->kernel(用户想告诉内核什么呢?):请OS帮我检测一下在readfds与writefds中,所有的fd状态
select返回时,kernel->user(是内核想告诉用户什么呢?):我检测的readfds或writefds中,已有读/写事件就绪
例:我想关注一下3和4read和write事件
将3和4文件描述符添加到readfds和writefds
成功返回就绪文件描述符总数。如果返回值为0,则时间过期,超过设置的等待时间。出错则返回-1,错误原因存于errno,此时参数readfds、writefds、exceptfds和timeout的值变成不可预测
EBADF文件描述词为无效的或该文件已关闭
EINTR此调用被信号所中断
EINVAL参数n为负值
ENOMEM核心内存不足
select调用,每一次都需要进行对所关心的fd进行重新设置
连接事件到来,在多路转接看来,都统一当作读时间就绪。
所以我们在监听套接字去accept时,不放在最前面,因为如果没有人连接我,那么accept一直处于阻塞状态。(accept阻塞过程就相当于等的过程,accept把底层的连接拿到上层的过程,就是一个IO的过程)。那么下面的代码就不会被调用,select就不能被调用。所以我们可以直接将监听套接字加入readfds去进行监听
sock.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#define BACK_LOG 5
#define NUM 1024
#define DEL_FD -1
namespace ns_sock{
class Sock{
public:
static int Socket(){
int sock = socket(AF_INET,SOCK_STREAM,0);
if(sock < 0){
std::cerr << "socket error!" << std::endl;
exit(1);
}
int opt = 1;
setsockopt(sock,SDL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
return sock;
}
static bool Bind(int sock,unsigned short port){
struct sockaddr_in local;
memset(&local,0,sieof(0));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(sock,(struct sockadd*)&local,sizeof(local)) < 0 ){
std::cerr << "bind error!" << std::endl;
exit(2);
}
return true;
}
static bool Listen(int sock,int backlog){
if(listen(sock,backlog) < 0){
std::cerr << "listen error" << std::endl;
exit(3);
}
return true;
}
};
}
select_server.hpp
#pragma once
#include "sock.hpp"
#include
#include
#include
namespace ns_select
{
using namespace ns_sock;
#define NUM (sizeof(fd_set) * 8)
const int g_default = 8080;
// class EndPoint{
// int fd;
// std::string buffer;
// };
class SelectServer
{
private:
u_int16_t port_;
int listen_sock_;
int fd_arrar_[NUM];
// EndPoint fd_array_[NUM];
public:
SelectServer(int port = g_default) : port_(port), listen_sock_(-1)
{
for (int i = 0; i < NUM; i++)
{
fd_arrar_[i] = -1;
}
}
void InitSelectServer()
{
listen_sock_ = Sock::Socket();
Sock::Bind(listen_sock_, port_);
Sock::Listen(listen_sock_);
fd_arrar_[0] = listen_sock_;
}
std::ostream& PrintFd()
{
for(int i = 0; i < NUM; i++)
{
if(fd_arrar_[i] != -1) std::cout << fd_arrar_[i] << ' ';
}
return std::cout;
}
// 1111 1111
// 0000 1111
void HandlerEvent(const fd_set &rfds)
{
//判断我的有效sock,是否在rfds中
for (int i = 0; i < NUM; i++)
{
if(-1 == fd_arrar_[i]) {
continue;
}
//如何区分: 新链接到来,真正的数据到来?
if(FD_ISSET(fd_arrar_[i], &rfds))
{
if(fd_arrar_[i] == listen_sock_)
{
//新链接到来
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock_, (struct sockaddr*)&peer, &len);
if(sock < 0)
{
std::cout << "accept error" << std::endl;
}
else
{
//不能直接读取新的sock,为什么要见它添加到数组就完了??
//将新的sock添加到文件描述符数组中!
// int index = -1;
int j = 0;
for(; j < NUM; j++)
{
if(fd_arrar_[j] == -1)
{
// index = j;
break;
}
}
// if(index == -1)
if(j == NUM)
{
std::cout << "fd_array 已经满了!" << std::endl;
close(sock);
}
else
{
fd_arrar_[j] = sock;
// fd_arrar_[index] = sock;
std::cout << "获取新的链接成功, sock: " << sock << " 已经添加到数组中了, 当前: " << std::endl;
PrintFd() << " [当前]" << std::endl;
}
}
}
else
{
// 数据到来
// 这样写是有BUG的!这里不解决,epoll
// 你能保证你用1024就能读取完毕吗??有没有可能有粘包问题??
// 网络通信,定制协议,业务场景有关
// 是不是每一个sock,都必须有自己独立的buffer
char buffer[1024];
ssize_t s = recv(fd_arrar_[i], buffer, sizeof(buffer), 0);
if( s > 0 )
{
buffer[s] = '\0';
std::cout << "clint say# " << buffer << std::endl;
}
else if(s == 0)
{
std::cout << "client quit ---- sock: " << fd_arrar_[i] << std::endl;
// 对端链接关闭
close(fd_arrar_[i]);
// 从rfds中,去掉该sock
fd_arrar_[i] = -1;
PrintFd() << " [当前]" << std::endl;
}
else
{
//读取异常,TODO
std::cerr << "recv error" << std::endl;
}
}
}
}
}
void Loop()
{
//这样写有问题吗??
//在服务器最开始的时候,我们只有一个sock,listen_sock
//有读事件就绪,读文件描述符看待的!
fd_set rfds; // 3, 4,5,6
// fd_set wfds;
// fd_set efds;
// FD_SET(listen_sock_, &rfds);
while (true)
{
// struct timeval timeout = {0, 0};
// 对位图结构进行清空
FD_ZERO(&rfds);
int max_fd = -1;
for (int i = 0; i < NUM; i++)
{
if (-1 == fd_arrar_[i])
continue;
FD_SET(fd_arrar_[i], &rfds);
if (max_fd < fd_arrar_[i])
max_fd = fd_arrar_[i];
}
// select是可以等待多个fd的,listen_sock_只是其中之一
// 如果有新的链接到来,一定对应的是有新的sock,你如何保证新的sock也被添加到select 中?
// rfds: 1111 1111 (输入)
// 1000 0000 (输出)
// select 要被使用,需要借助于一个第三方数组,管理所有的有效sock
// fd_set: 不要把它当做具有sock保存的功能,它只有互相通知(内核<->用户)的能力
// select模型: 要保存历史所有的sock(为什么?),需要借助于第三方的数组.
// select : 通知sock就绪之后,上层读取,可能还需要继续让select帮我们进行检测,对rfds进行重复设置
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case 0:
std::cout << "timeout ..." << std::endl;
break;
case -1:
std::cout << "select error" << std::endl;
break;
default:
// select成功, 至少有一个fd是就绪的
HandlerEvent(rfds);
// std::cout << "有事件发生了..." << std::endl;
break;
}
}
}
~SelectServer()
{
//没什么意义
if (listen_sock_ >= 0)
close(listen_sock_);
}
};
} // namespace ns_select
server.cc
#include "select_server.hpp"
using namespace ns_select;
int main()
{
SelectServer *svr = new SelectServer();
svr->InitSelectServer();
svr->Loop();
// std::cout << sizeof(fd_set) << std::endl;
return 0;
}
不会,fd_set是一个具体的数据类型->大小是确定的,是一个位图结构。而我们的文件描述符的个数也是有上限的。
如:在云服务器上的上限为1024,而我用的云服务器来编写上述代码,所以我写的1024,一般写出
#define NUM (sizeof(fd_set)*8)
更好
缺点
优点
那为什么我们不用多进程和多线程?因为这样太耗费系统资源了
适用场景
如果有一定的连接,每个连接都很活跃,是不是必须得使用多路转接
不适合
多路转接的适合场景为:适合有大量的连接,但是只有少量是活跃的
剩下的poll、epoll下一篇文章讲