前言
我们知道Android消息机制是通过Handler、Looper与MessageQueue建立的整个体系,Looper.loop()方法是开启了一个死循环,不停地从MessageQueue中取消息,但是为什么死循环不会浪费CPU资源呢?这就要开启我们的EPoll了。
Message next() {
for (;;) {
// 调用了native pollonce
nativePollOnce(ptr, nextPollTimeoutMillis);
}
}
前提铺垫
- 同步/异步 IO
同步:执行IO操作时,CPU等待,直到IO操作结束,再继续执行CPU后续操作。
异步:执行IO操作时,CPU执行后续操作,无需等待IO操作结束,结束后通知CPU即可。
形象化表达 点餐:
同步:去KFC点餐,服务员说蛋挞还得等20分钟出炉,那么客户准备吃完蛋挞再去逛街,所以就一直等着蛋挞直到蛋挞出炉再去逛街。
异步: 去KFC点餐,服务员说蛋挞还得等20分钟出炉,那么客户准备吃完蛋挞再去逛街,所以就先去逛街,20分钟后再来取蛋挞。 - 阻塞/非阻塞IO
理论上,阻塞 = 同步,异步 = 非阻塞
阻塞/非阻塞IO在不同的语境中与同步/异步IO是代表不同的意思的,这就涉及对IO模型的讨论了,这个我们后续会说。 - 用户空间/内核空间
- 缓存IO
缓存IO称为标准IO,大多数的文件系统默认的IO操作都是缓存I/O。在Linux中,数据会先从磁盘复制到内核控件的缓冲区,然后从内核空间缓冲区复制到应用程序的地址控件。
优点:
1、缓存IO在一定程度上将用户空间与内核控件进行了数据分离,确保了系统本身的应用安全。
2、减少了读取磁盘的次数,从而提升性能。
缺点:
数据在进行传输的过程中,需要多次进行应用程序地址空间(用户空间)与内核缓存之间进行多次数据拷贝过程,因为应用程序地址空间无法直接与磁盘进行操作,所以拷贝所带来的CPU开销是非常大的。 - 进程的阻塞
正在执行任务的进程,由于期待的某些时间还未发生,如请求系统资源失败、等待某些操作未完成、新数据尚未到达或没有新的工作等等,则由系统自动执行Block阻塞原语,使之放弃CPU使用权,移交给其他进程,所以不占用CPU资源。 - 文件描述符
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
在Linux中,文件描述符在形式上就是一个非负整数。相当于一个索引值,指向内核为每一个进程所维护该进程打开的文件的记录表。程序中一个文件对应一个文件描述符,是内核在创建或打开时返回的。
网络数据 I/O
计算机是如何接收网络数据的呢?我们知道计算机网卡是用来进行无线通讯的,网卡首先接收到的数据,通过网卡接口传输到硬件电路中,再将数据写入到内存中某个地址。其中涉及到DMA(Direct Memory Access)传输以及IO通路选择。
- DMA(Direct Memory Access)
根据计组知识,我们知道CPU是通过地址总线,数据总线,控制信号总线来与系统其他部分进行数据通信的。地址线用于提供内存或I/O设备的地址,即指明需要读/写数据的具体位置。数据线用于在 CPU 和内存或 I/O 设备之间提供数据传输的通道,而控制线则负责指挥执行的具体读/写操作。所以说32位机器代表的是内部地址线和数据线的根数有32根。那么DMA数据传输的方式,就是说外设试图通过总线向另一个设备发送数据,并且是大量数据的时候,外设会先去请求CPU发送DMA请求信号。外设通过专门的接口电路(DMA控制器),向CPU发出接管总线控制权,当CPU接收到信号后,在当前的总线周期结束后,会按照DMA信号的优先级和提出DMA请求的先后顺序进行响应DMA信号。CPU会让出总线控制权,这时外设持有总线控制权,可以直接与存储器进行数据通信,不在需要CPU干预,等待数据发送完毕之后,会向CPU发送DMA结束信号,归还总线控制权。
中断信号
我们在学习计组的时候,一定听说过CPU中断信号。也就是说,若硬件产生的信号,需要让CPU进行响应,换句话说,硬件产生的信号一般为中断信号,因为该信号一旦不作出相应,就会将数据丢失掉。比如,当计算机收到了断电信号时,CPU会立即去通知保存数据的程序,防止数据丢失,电容可以保存一些电量用于提供CPU运行一小段时间。那么计算机如何知道已经接受到了网卡数据呢?网卡写入内存以后,网卡会向CPU发出一个中断信号,操作系统就能够知道有新数据到来,通过网卡中断程序去处理数据。
进程阻塞为什么不会消耗CPU资源?
阻塞的概念我们已经理解了,我们先看一段基础的网络编程代码。
int socket = createSocket()
bindSocket();
listenSocket();
int c = accept(socket,.....);
// 死循环之类的从socket inputStream 阻塞线程
recv(c,...)
先创建Socket,绑定,监听 和 接受 在通过死循环接收数据。recv 是一个阻塞方法,当程序执行recv就一直等到收到数据再往下执行。
所以操作系统为了执行多任务,也就类似进程调度,会分为运行与等待状态等等(线程同样如此)。运行就是所谓的获取到CPU使用权,阻塞就是等待状态,也就失去CPU使用权。CPU就是不停地进行各个进程间的使用切换,所以由于速度处理的很快,看上去是同时执行多个任务。
如何知道哪些任务要进行CPU处理,哪些需要让出CPU呢?
-
工作队列
进程运行状态,将会把该进程依次放入到工作执行队列中,需要cpu执行处理的队列。
-
等待队列
进程一旦处于阻塞状态,也就是等待状态时,比如Socket在执行创建语句时,操作系统会创建一个由文件系统管理的Socket对象,这个对象包含发送、接收缓冲区以及等待队列成员。
当最终进程A执行recv函数的时候,操作系统会将进程A从工作队列移除到等待队列中,这时候工作队列只剩下进程B、C,之后CPU轮流执行进程B、C的代码。所以CPU不会区执行等待队列中的进程,因此也就不会浪费CPU资源。
- 唤醒进程
唤醒进程这里,一旦Socket接收到消息,我们之前描述的会收到一个中断信号,告诉CPU接收到了消息 同样的,又将进程A重新放回工作队列中。
Epoll在Android中的应用
Code from Android Data Source 5.1 -- Looper.cpp
Epoll 初始化
Looper::Looper(bool allowNonCallbacks) :
mAllowNonCallbacks(allowNonCallbacks), mSendingMessage(false),
mResponseIndex(0), mNextMessageUptime(LLONG_MAX) {
int wakeFds[2];
// Android 采用pipe通道进行数据的通信 result = 0成功,失败返回-1
int result = pipe(wakeFds);
// 管道读端 fd
mWakeReadPipeFd = wakeFds[0];
// 管道写端 fd
mWakeWritePipeFd = wakeFds[1];
result = fcntl(mWakeReadPipeFd, F_SETFL, O_NONBLOCK);
result = fcntl(mWakeWritePipeFd, F_SETFL, O_NONBLOCK);
mIdling = false;
// Allocate the epoll instance and register the wake pipe.
// 创建Epoll句柄,返回一个文件描述符 方法参数为EPOLL_SIZE_HINT = 8的内核监听容量
mEpollFd = epoll_create(EPOLL_SIZE_HINT);
struct epoll_event eventItem;
memset(& eventItem, 0, sizeof(epoll_event)); // zero out unused members of data field union
eventItem.events = EPOLLIN;
eventItem.data.fd = mWakeReadPipeFd;
// epoll 事件注册函数 第一个传入mEpollFd文件描述符,第二个参数代表动作,EPOLL_CTL_ADD代表注册新的fd到epfd中
// 第三个参数 代表监听哪个文件描述符 也就是说在Android中epoll是通过监听读端fd,来告知MessageQueue来数据了
// 第四个参数 用于处理监听什么事件
result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeReadPipeFd, & eventItem);
}
其中epoll_ctl()方法中的第二个参数代表执行操作宏定义,分为三种。
- EPOLL_CTL_ADD
添加新的事件到epoll中 - EPOLL_CTL_MOD
修改epoll中的事件 - EPOLL_CTL_DEL
删除epoll中的事件
接下来,MessageQueue next()方法实则是调用了pollOnce方法,我们来看下。
android_os_MessageQueue.cpp
static void android_os_MessageQueue_nativePollOnce(JNIEnv* env, jobject obj,
jlong ptr, jint timeoutMillis) {
NativeMessageQueue* nativeMessageQueue = reinterpret_cast<NativeMessageQueue*>(ptr);
nativeMessageQueue->pollOnce(env, obj, timeoutMillis);
}
int Looper::pollOnce(int timeoutMillis, int* outFd, int* outEvents, void** outData) {
//.... 省略了大量代码
// 最终调用了 pollInner 方法并传入了超时时长
result = pollInner(timeoutMillis);
}
}
int Looper::pollInner(int timeoutMillis) {
// Poll.
int result = POLL_WAKE;
mResponses.clear();
mResponseIndex = 0;
// We are about to idle.
// 是否是空转状态 也就是在调用epoll_wait时,加入一个标记,表明是否阻塞
mIdling = true;
struct epoll_event eventItems[EPOLL_MAX_EVENTS];
// 执行epoll_wait方法进行阻塞,等待fd socket 返回数据
int eventCount = epoll_wait(mEpollFd, eventItems, EPOLL_MAX_EVENTS, timeoutMillis);
// No longer idling.
// 取消空转状态,表明目前已经收到了数据
mIdling = false;
// Acquire lock.
// 加锁用于后续的fd遍历
mLock.lock();
// 伪代码...
// epoll_wait 之后一般是一个循环
for (int i = 0; i < eventCount; i++) {
int fd = eventItems[i].data.fd;
uint32_t epollEvents = eventItems[i].events;
// 若接收到的数据来源是正在监听的pipe通道所开辟的fd,则说明当前Looper绑定的Thread中的MessageQueue接收到了数据,
// 可以继续走Java代码中的For循环之后的代码了,继续获取CPU执行权。
if (fd == mWakeReadPipeFd) {
if (epollEvents & EPOLLIN) {
// 唤醒 也就是说开始读管道中的数据
awoken();
} else {
//... 省略
}
} else {
//... 省略
}
}
}
void Looper::awoken() {
char buffer[16];
ssize_t nRead;
do {
// 读管道中的数据
nRead = read(mWakeReadPipeFd, buffer, sizeof(buffer));
} while ((nRead == -1 && errno == EINTR) || nRead == sizeof(buffer));
}
以上就是Epoll在android中的应用。具体细节,之后还会另写一篇文章详细说明。
要解决的问题
一个应用进程对应一个Socket文件,操作系统中应用无上限,因此代表有多个Socket文件接收到信息后,要知道到底发送给那个应用进程?所有的进程关心的Socket都由一个线程进行处理,所以也就是构成I/O多路复用问题。
数据结构
当然这些Socket集合,当FD=XXX发生了事件变化,那么是那些进程需要关心的呢?
这里就涉及到Socket集合的数据结构了。
一个Socket文件,能够由多个进程使用,而一个进程也可以使用多个Socket文件,因此Socket与进程是多对多的关系。另外,一个Socket也会有不同的事件类型。所以操作系统将很难判断出将哪样的事件给那个进程。
链表/数组线性结构
Select与Poll都是采用了线性结构。Select允许用户传入三个集合,这三个集合分别对应了Socket的三种不同的状态的集合,读集合、写集合、异常集合。
Select
fd_set read_fd_set, write_fd_set, error_fd_set;
while(true) {
// 进程读关心、写关心、异常时关心的Socket集合。
select(..., &read_fd_set, &write_fd_set, &error_fd_set);
for (i = 0; i < FD_SETSIZE; ++i)
if (FD_ISSET (i, &read_fd_set)){
// Socket可以读取
} else if(FD_ISSET(i, &write_fd_set)) {
// Socket可以写入
} else if(FD_ISSET(i, &error_fd_set)) {
// Socket发生错误
}
}
因此呢,每次进行Select操作的时候呢,会阻塞当前的线程,在阻塞期间所有的操作系统产生的每个信息,都会通过遍历的方式去查找这个消息是否存在于这三个集合之中。其中 FD_SETSIZE代表最大并发数量,一般是1024个并发。
Select缺点
- 每次需要遍历这三种集合,并且需要进行比较操作,浪费了很大的时间。
- 不是一个很好的编程模型,不应该让用户来设置自己的集合而是通过系统的Api来拿到对应的消息。
Poll
while(true) {
events = poll(fds, ...)
for(evt in events) {
fd = evt.fd;
type = evt.revents;
if(type & POLLIN ) {
// 有数据需要读,读取fd中的数据
} else if(type & POLLOUT) {
// 可以写入数据
}
else ...
}
}
Poll 在性能上呢和Select差别不大,还是需要进行遍历,只是说在编程模型上进行了优化处理。
总结Select/Poll
都是阻塞式IO,都需要进行Socket线性集合的遍历找到当前进程所需要的Socket。
EPoll
为了解决上述的问题,epoll 通过更好的方案实现了从操作系统订阅消息。epoll 将进程关注的文件描述符存入一棵二叉搜索树,通常是红黑树的实现。在这棵红黑树当中,Key 是 Socket 的编号,值是这个 Socket 关注的消息。因此,当内核发生了一个事件:比如 Socket 编号 1000 可以读取。这个时候,可以马上从红黑树中找到进程是否关注这个事件。另外当有关注的事件发生时,epoll 会先放到一个队列当中。当用户调用epoll_wait时候,就会从队列中返回一个消息。epoll 函数本身是一个构造函数,只用来创建红黑树和队列结构。epoll_wait调用后,如果队列中没有消息,也可以马上返回。因此epoll是一个非阻塞模型。
总结一下,select/poll 是阻塞模型,epoll 是非阻塞模型。当然,并不是说非阻塞模型性能就更好。在多数情况下,epoll 性能更好是因为内部有红黑树的实现。
优点
- 内部使用红黑树减少了内核的比较操作;
- 对于程序员而言,非阻塞的模型更容易处理各种各样的情况。程序员习惯了写出每一条语句就可以马上得到结果,这样不容易出 Bug。