协程
协程,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程,协程一定是在单线程运行的。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:
协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。
协程的好处:
- 无需线程上下文切换的开销
- 无需原子操作锁定及同步的开销
- 方便切换控制流,简化编程模型
- 高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。
缺点:
- 无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
- 进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序
使用yield实现协程操作例子
import time
import queue
def consumer(name):
print("--->starting eating baozi...")
while True:
new_baozi = yield
print("[%s] is eating baozi %s" % (name,new_baozi))
#time.sleep(1)
def producer():
r = con.__next__()
r = con2.__next__()
n = 0
while n < 5:
n +=1
con.send(n)
con2.send(n)
print("\033[32;1m[producer]\033[0m is making baozi %s" %n )
if __name__ == '__main__':
con = consumer("c1")
con2 = consumer("c2")
p = producer()
Greenlet
#!/usr/bin/env python
# -*- coding:utf-8 -*-
from greenlet import greenlet
def test1():
print 12
gr2.switch()
print 34
gr2.switch()
def test2():
print 56
gr1.switch()
print 78
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
Gevent
Gevent 是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。
import gevent
def foo():
print('Running in foo')
gevent.sleep(1)
print('Explicit context switch to foo again')
def bar():
print('Explicit context to bar')
gevent.sleep(1)
print('Implicit context switch back to bar')
gevent.joinall([
gevent.spawn(foo), # 产生一个协成
gevent.spawn(bar),
])
'''Running in foo
Explicit context to bar
Explicit context switch to foo again
Implicit context switch back to bar
结果可以看出 第一次启动的时候遇到sleep,进行切换,继续往下执行,最后sleep完在返回信息
'''
同步与异步的性能区别
import gevent
def task(pid):
"""
Some non-deterministic task
"""
gevent.sleep(0.5)
print('Task %s done' % pid)
def synchronous():
for i in range(1,10):
task(i)
def asynchronous():
threads = [gevent.spawn(task, i) for i in range(10)]
gevent.joinall(threads)
print('Synchronous:')
synchronous()
print('Asynchronous:')
asynchronous()
'''
效果可以看出 同步是串行的,每次循环都等0.5秒钟,而异步是并行的,整体只等0.5秒钟。
'''
上面程序的重要部分是将task函数封装到Greenlet内部线程的gevent.spawn
。 初始化的greenlet列表存放在数组threads
中,此数组被传给gevent.joinall
函数,后者阻塞当前流程,并执行所有给定的greenlet。执行流程只会在 所有greenlet执行完后才会继续向下走。
遇到IO阻塞时会自动切换任务
from gevent import monkey; monkey.patch_all()
import gevent
from urllib.request import urlopen
def f(url):
print('GET: %s' % url)
resp = urlopen(url)
data = resp.read()
print('%d bytes received from %s.' % (len(data), url))
gevent.joinall([
gevent.spawn(f, 'https://www.python.org/'),
gevent.spawn(f, 'https://www.yahoo.com/'),
gevent.spawn(f, 'https://github.com/'),
])
# monkey.patch_all 自动将阻塞设置为非阻塞
通过gevent实现单线程下的多socket并发
server side
import sys
import socket
import time
import gevent
from gevent import socket,monkey
monkey.patch_all()
def server(port):
s = socket.socket()
s.bind(('0.0.0.0', port))
s.listen(500)
while True:
cli, addr = s.accept()
gevent.spawn(handle_request, cli)
def handle_request(s):
try:
while True:
data = s.recv(1024)
print("recv:", data)
s.send(data)
if not data:
s.shutdown(socket.SHUT_WR)
except Exception as ex:
print(ex)
finally:
s.close()
if __name__ == '__main__':
server(8001)
论事件驱动与异步IO
事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。
在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。
在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。
在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。
当我们面对如下的环境时,事件驱动模型通常是一个好的选择:
- 程序中有许多任务,而且…
- 任务之间高度独立(因此它们不需要互相通信,或者等待彼此)而且…
- 在等待事件到来时,某些任务会阻塞。
当应用程序需要在任务间共享可变的数据时,这也是一个不错的选择,因为这里不需要采用同步处理。
网络应用程序通常都有上述这些特点,这使得它们能够很好的契合事件驱动编程模型。
Select\Poll\Epoll异步IO
sellect、poll、epoll三者的区别
select
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
poll
poll在1986年诞生于System V Release 3,它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。
epoll
直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll,它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。
另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。
sellect、poll、epoll
Python select
Python的select()方法直接调用操作系统的IO接口,它监控sockets,open files, and pipes(所有带fileno()方法的文件句柄)何时变成readable 和writeable, 或者通信错误,select()使得同时监控多个连接变的简单,并且这比写一个长循环来等待和监控多客户端连接要高效,因为select直接通过操作系统提供的C的网络接口进行操作,而不是通过Python的解释器。
注意:Using Python’s file objects with select() works for Unix, but is not supported under Windows.
接下来通过例子要以了解select 是如何通过单进程实现同时处理多个非阻塞的socket连接的。
import select
import socket,time
server = socket.socket()
# server.setblocking(0)
server_address = ('localhost', 10000)
server.bind(server_address)
server.listen(5)
INPUT = [server,] # INPUT 在此的作用为 将服务端的socket句柄加入到列表,让select初始监听服务端的socket句柄,如果客户端连接进来,则将客户端的socket句柄加入到input列表中
# print(INPUT)
OUTPUT=[]
while True:
r,w,e=select.select(INPUT,OUTPUT,INPUT,1) # 监听句柄序列 select里INPUT,OUTPUT,INPUT,1代表客户端的输入(sys.stdin,),输出(sys.stdout),异常错误(except),超时时间,如果某一个句柄发生变化,会赋值给相对应设置的变量,下面循环取做他们的操作
time.sleep(2)
for i in r:
if i == server: #循环r,如果没有客户端请求,for循环一直循环r,client请求server,则触发select,加入到INPUT列表中,判断是否是server被触发,是则接受客户端的请求
conn,address = i.accept() #
INPUT.append(conn)# 将客户端的socket加入到列表中监听
print(address)
else: #如果被select触发的客户端过来的socket,则进行接收
client_data=i.recv(1024)
print(client_data.decode())
i.send(client_data)
select第二个参数实例(模仿读写分离)
import select
import socket,time
server = socket.socket()
# server.setblocking(0)
server_address = ('localhost', 10000)
server.bind(server_address)
server.listen(5)
INPUT = [server,] # INPUT 在此的作用为 讲服务端的socket句柄加入到列表,让select初始监听服务端的socket句柄,如果客户端连接进来,则将客户端的socket句柄加入到input列表中
# print(INPUT)
OUTPUT=[]
while True:
r,w,e=select.select(INPUT,OUTPUT,INPUT,1)
time.sleep(2)# 读写分离
#如果r有数据,读
#读到数据了 将客户端的socket写入到w
for i in r:
if i == server: #循环r,如果没有客户端请求,for循环一直循环r,client请求server,则触发select,加入到INPUT列表中,判断是否是server被触发,是则接受客户端的请求
conn,address = i.accept() #
INPUT.append(conn)# 将客户端的socket加入到列表中监听
print(address)
else: #如果被select触发的客户端过来的socket,则进行接收
try:
client_data=i.recv(1024)
if len(client_data) != 0:
print(client_data.decode())
OUTPUT.append(i)
except Exception:
INPUT.remove(i)
for wi in w:
wi.send(bytes("123","utf8"))
print("11111")
OUTPUT.remove(wi)
客户端
import socket
cl=socket.socket()
cl.connect(("127.0.0.1",10000))
while True:
data=input("==>>>:")
cl.sendall(bytes(data,"utf8"))
msg=cl.recv(1024)
print(msg.decode())
上述的代码测试就会看到,我们让server端每次都发送的相同的数据,这样有什么卵用。 那如何让客户端将用户输入的数据返回回去呢? 类似上上的效果,代码如下
import select
import socket,time
from queue import Queue
server = socket.socket()
# server.setblocking(0)
server_address = ('localhost', 10000)
server.bind(server_address)
server.listen(5)
INPUT = [server,] # INPUT 在此的作用为 讲服务端的socket句柄加入到列表,让select初始监听服务端的socket句柄,如果客户端连接进来,则将客户端的socket句柄加入到input列表中
# print(INPUT)
OUTPUT=[]
message={}
while True:
r,w,e=select.select(INPUT,OUTPUT,INPUT,1)
# time.sleep(2)
# 读写分离
#如果r有数据,读
#读到数据了 将客户端的socket写入到w
for i in r:
if i == server: #循环r,如果没有客户端请求,for循环一直循环r,client请求server,则触发select,加入到INPUT列表中,判断是否是server被触发,是则接受客户端的请求
conn,address = i.accept() #
INPUT.append(conn)# 将客户端的socket加入到列表中监听
#在此的效果为:
'''
message{
conn1:[data1,data2,data3...] # conn1表示客户端1的socket地址
conn2:[data1,data2,data3...] # conn2表示客户端2的socket地址
}
'''
else: #如果被select触发的客户端过来的socket,则进行接收
try:
client_data=i.recv(1024)
if len(client_data) != 0:
OUTPUT.append(i) # 接收到客户端的数据了
message[i] = Queue() # 将message字典的值设置为队列。
message[i].put(client_data) # 将接收的数据上传到队列中
except Exception:
INPUT.remove(i)
for wi in w:
wi.send(message[wi].get())
OUTPUT.remove(wi) # 发送过去数据后移除OUTPUT里client的socket句柄,因为每次接收时候也会创建,如果不删除客户端断开后句柄也会存在,这样就慢慢就增大了内存
message.pop(wi) # 移除message里的client的socket句柄
完整版的select:
import select
import socket
import sys
import queue
# 创建一个TCP/IP socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setblocking(False)
# 绑定socket到指定端口
server_address = ('localhost', 10000)
print(sys.stderr, 'starting up on %s port %s' % server_address)
server.bind(server_address)
# 监听连接的地址
server.listen(5)
inputs = [server]
# Socket的读操作
outputs = []
# socket的写操作
message_queues = {}
while inputs:
# Wait for at least one of the sockets to be ready for processing
print( '\nwaiting for the next event')
readable, writable, exceptional = select.select(inputs, outputs, inputs)
# 监听句柄序列,如果某个发生变化,select的第一个rLest会拿到数据,output只要有数据wLest就能获取到,select的第三个参数inputs用来监测异常,并赋值给exceptional。
# 监听inputs,outputs,inputs 如果他们的值有变化,就将分别赋值给readable,writable,exceptional。
for s in readable:
# 遍历readable的值。
if s is server:
connection, client_address = s.accept()
# 如果s 是server,那么server socket将接收连接。
print('new connection from', client_address)
# 打印出连接客户端的地址。
connection.setblocking(False)
# 设置socket 为非阻塞模式。
inputs.append(connection)
# 因为有读操作发生,所以将此连接加入inputs
message_queues[connection] = queue.Queue()
# 为每个连接创建一个queue队列。使得每个连接接收到正确的数据。
else:
data = s.recv(1024)
# 如果s不是server,说明客户端连接来了,那么就接受客户端的数据。
if data:
# 如果接收到客户端的数据
print(sys.stderr, 'received "%s" from %s' % (data, s.getpeername()) )
message_queues[s].put(data)
# 将收到的数据放入队列中
if s not in outputs:
outputs.append(s)
# 将socket客户端的连接加入select的output中,并且用来返回给客户端数据。
else:
print('closing', client_address, 'after reading no data')
# 如果没有收到客户端发来的空消息,则说明客户端已经断开连接。
if s in outputs:
outputs.remove(s)
# 既然客户端都断开了,我就不用再给它返回数据了,所以这时候如果这个客户端的连接对象还在outputs列表中,就把它删掉
inputs.remove(s)
# inputs中也删除掉
s.close()
# 把这个连接关闭掉
del message_queues[s]
# 删除此客户端的消息队列
for s in writable:
# 遍历output的数据
try:
next_msg = message_queues[s].get_nowait()
except queue.Empty:
# 获取对应客户端消息队列中的数据,如果队列中的数据为空,从消息队列中移除此客户端连接。
print('output queue for', s.getpeername(), 'is empty')
outputs.remove(s)
else:
print( 'sending "%s" to %s' % (next_msg, s.getpeername()))
s.send(next_msg)
# 如果消息队列有数据,则发送给客户端。
for s in exceptional:
# 处理 "exceptional conditions"
print('handling exceptional condition for', s.getpeername() )
inputs.remove(s)
# 取消对出现异常的客户端的监听
if s in outputs:
outputs.remove(s)
# 移除客户端的连接对象。
s.close()
# 关闭此socket连接
del message_queues[s]
# 删除此消息队列。
'''
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,
轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的连接
'''
epoll
__auther__ = 'Victor'
#--------------这是一个epoll的例子--------------
import socket, select
# 'windows'下不支持'epoll'
EOL1 = b'\n\n'
EOL2 = b'\n\r\n'
response = b'HTTP/1.0 200 OK\r\nDate: Mon, 1 Jan 1996 01:01:01 GMT\r\n'
response += b'Content-Type: text/plain\r\nContent-Length: 13\r\n\r\n'
response += b'Hello, world!'
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serversocket.bind(('0.0.0.0', 8080))
serversocket.listen(1)
# 建立socket连接。
serversocket.setblocking(0)
# 因为socket本身是阻塞的,setblocking(0)使得socket不阻塞
epoll = select.epoll()
# 创建一个eopll对象
epoll.register(serversocket.fileno(), select.EPOLLIN)
# 在服务器端socket上面注册对读event的关注,一个读event随时会触发服务器端socket去接收一个socket连接。
try:
connections = {}; requests = {}; responses = {}
# 生成3个字典,connection字典是存储文件描述符映射到他们相应的网络连接对象
while True:
events = epoll.poll(1)
# 查询epoll对象,看是否有任何关注的event被触发,参数‘1’表示,会等待一秒来看是否有event发生,如果有任何感兴趣的event发生在这次查询之前,这个查询就会带着这些event的列表立即返回
for fileno, event in events:
# event作为一个序列(fileno,event code)的元组返回,fileno是文件描述符的代名词,始终是一个整数。
if fileno == serversocket.fileno():
# 如果一个读event在服务器端socket发生,就会有一个新的socket连接可能被创建。
connection, address = serversocket.accept()
# 服务器端开始接收连接和客户端地址
connection.setblocking(0)
# 设置新的socket为非阻塞模式
epoll.register(connection.fileno(), select.EPOLLIN)
# 为新的socket注册对读(EPOLLIN)event的关注
connections[connection.fileno()] = connection
requests[connection.fileno()] = b''
responses[connection.fileno()] = response
elif event & select.EPOLLIN:
requests[fileno] += connections[fileno].recv(1024)
# 如果发生一个读event,就读取从客户端发过来的数据。
if EOL1 in requests[fileno] or EOL2 in requests[fileno]:
epoll.modify(fileno, select.EPOLLOUT)
# 一旦完成请求已经收到,就注销对读event的关注,注册对写(EPOLLOUT)event的关注,写event发生的时候,会回复数据给客户端。
print('-'*40 + '\n' + requests[fileno].decode()[:-2])
# 打印完整的请求,证明虽然与客户端的通信是交错进行的,但是数据可以作为一个整体来组装和处理。
elif event & select.EPOLLOUT:
# 如果一个写event在一个客户端socket上面发生,他会接受新的数据以便发送到客户端。
byteswritten = connections[fileno].send(responses[fileno])
responses[fileno] = responses[fileno][byteswritten:]
if len(responses[fileno]) == 0:
# 每次发送一部分响应数据,直到完整的响应数据都已经发送给操作系统等待传输给客户端。
epoll.modify(fileno, 0)
# 一旦完整的响应数据发送完成,就不再关注读或者写event。
connections[fileno].shutdown(socket.SHUT_RDWR)
# 如果一个连接显式关闭,那么socket shutdown是可选的,在这里这样使用,是为了让客户端首先关闭。
# shutdown调用会通知客户端socket没有更多的数据应该被发送或者接收,并会让功能正常的客户端关闭自己的socket连接。
elif event & select.EPOLLHUP:
# HUP挂起event表明客户端socket已经断开(即关闭),所以服务器端也需要关闭,没有必要注册对HUP event的关注,在socket上面,他们总是会被epoll对象注册。
epoll.unregister(fileno)
# 注销对此socket连接的关注。
connections[fileno].close()
# 关闭socket连接。
del connections[fileno]
finally:
epoll.unregister(serversocket.fileno())
# 去掉已经注册的文件句柄
epoll.close()
# 关闭epoll对象
serversocket.close()
# 关闭服务器连接
# 打开的socket连接不需要关闭,因为Python会在程序结束时关闭, 这里的显示关闭是个好的习惯。
'''
首先我们来定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。
不管是文件,还是套接字,还是管道,我们都可以把他们看作流。
之后我们来讨论I/O的操作,通过read,我们可以从流中读入数据;通过write,我们可以往流写入数据。现在假定一个情形,
我们需要从流中读数据,但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),
这时候该怎么办?
阻塞:阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);
那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。
非阻塞忙轮询:接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”
很明显一般人不会用第二种做法,不仅显很无脑,浪费话费不说,还占用了快递员大量的时间。
大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。
为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),
当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
假设有一个管道,进程A为管道的写入方,B为管道的读出方。
假设一开始内核缓冲区是空的,B作为读出方,被阻塞着。然后首先A往管道写入,这时候内核缓冲区由空的状态变到非空状态,内核就会产生一个事件告诉B该醒来了,
这个事件姑且称之为“缓冲区非空”。
但是“缓冲区非空”事件通知B后,B却还没有读出数据;且内核许诺了不能把写入管道中的数据丢掉这个时候,A写入的数据会滞留在内核缓冲区中,如果内核也缓冲区满了,
B仍未开始读数据,最终内核缓冲区会被填满,这个时候会产生一个I/O事件,告诉进程A,你该等等(阻塞)了,我们把这个事件定义为“缓冲区满”。
假设后来B终于开始读数据了,于是内核的缓冲区空了出来,这时候内核会告诉A,内核缓冲区有空位了,你可以从长眠中醒来了,继续写数据了,我们把这个事件叫做“缓冲区非满”
也许事件Y1已经通知了A,但是A也没有数据写入了,而B继续读出数据,知道内核缓冲区空了。这个时候内核就告诉B,你需要阻塞了!,我们把这个时间定为“缓冲区空”。
这四个情形涵盖了四个I/O事件,缓冲区满,缓冲区空,缓冲区非空,缓冲区非满(注都是说的内核缓冲区,且这四个术语都是我生造的,仅为解释其原理而造)。
这四个I/O事件是进行阻塞同步的根本。(如果不能理解“同步”是什么概念,请学习操作系统的锁,信号量,条件变量等任务同步方面的相关知识)。
然后我们来说说阻塞I/O的缺点。但是阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),
很不幸这两种方法效率都不高。
于是再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):
while true {
for i in stream[]; {
if i has data
read until unavailable
}
}
我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。
这里要补充一点,阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。
为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,
可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流
(于是我们可以把“忙”字去掉了)。代码长这样:
while true {
select(streams[])
for i in streams[] {
if i has data
read until unavailable
}
}
于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流
(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。
但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,没一次无差别轮询时间就越长。再次
说了这么多,终于能好好解释epoll了
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。
(复杂度降低到了O(1))
在讨论epoll的实现细节之前,先把epoll的相关操作列出:
epoll_create 创建一个epoll对象,一般epollfd = epoll_create()
epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件
比如
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//注册缓冲区非空事件,即有数据流入
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//注册缓冲区非满事件,即流可以被写入
epoll_wait(epollfd,...)等待直到注册的事件发生
(注:当对一个非阻塞流的读写发生缓冲区满或缓冲区空,write/read会返回-1,并设置errno=EAGAIN。而epoll只关心缓冲区非满和缓冲区非空事件)。
一个epoll模式的代码大概的样子是:
while true {
active_stream[] = epoll_wait(epollfd)
for i in active_stream[] {
read or write till
}
}
'''