同步异步,阻塞非阻塞,IO模型,边缘触发(Edge Trigger)和条件触发(Level Trigger)
1.同步和异步
同步和异步其实是指CPU时间片的利用,主要看请求发起方对消息结果的获取是主动发起的,还是被动通知的,如下图所示。如果是请求方主动发起的,一直在等待应答结果(同步阻塞),或者可以先去处理其他事情,但要不断轮询查看发起的请求是否有应答结果(同步非阻塞),因为不管如何都要发起方主动获取消息结果,所以形式上还是同步操作。如果是由服务方通知的,也就是请求方发出请求后,要么一直等待通知(异步阻塞),要么先去干自己的事(异步非阻塞)。当事情处理完成后,服务方会主动通知请求方,它的请求已经完成,这就是异步。异步通知的方式一般通过状态改变、消息通知或者回调函数来完成,大多数时候采用的都是回调函数。
2.阻塞和非阻塞
阻塞和非阻塞在计算机的世界里,通常指针对I/O的操作,如网络I/0和磁盘I/0等。那么什么是阻塞和非阻塞呢?简单地说,就是我们调用了一个函数后,在等待这个函数返回结果之前,当前的线程是处于挂起状态还是运行状态。如果是挂起状态,就意味着当前线程什么都不能干,就等着获取结果,这就是同步阻塞;如果仍然是运行状态,就意味着当前线程是可以继续处理其他任务的,但要时不时地看一下是否有结果了,这就是同步非阻塞。具体如下图所示。
3.实际生活场景
同步、异步、阻塞和非阻塞可以组合成上面提到过的四种结果。举个例子,比如我们去照相馆拍照,拍完照片之后,商家说需要30min左右才能洗出来照片。
(1)这个时候,如果我们一直在店里面什么都不干,一直等待直到洗完照片,这个过程就叫同步阻塞。
(2)当然,大部分人很少这么干,更多的是大家拿起手机开始看电视,看一会儿就会问老板洗完没,老板说没洗完,然后接着看,再过一会儿接着问,直到照片洗完,这个过程就叫同步非阻塞。
(3)由于店里生意太好了,越来越多的人过来拍,店里面快没地方坐了,老板说你把手机号留下,我一会儿洗好了就打电话告诉你过来取,然后你去外面找了一个长凳开始躺着睡觉等待老板打电话,什么都不干,这个过程就叫异步阻塞(实际不应用)。
(4)当然实际情况是,大家可能会先去逛街或者吃饭,或者做其他活动,这样一来,两不耽误,这个过程就叫异步非阻塞(效率最高)。
4.小结
从上面的描述中,我们能够看到阻塞和非阻塞通常是指在客户端发出请求后,在服务端处理这个请求的过程中,客户端本身是直接挂起等待结果,还是继续做其他的任务。而异步和同步则是对于请求结果的获取是客户端主动获取结果,还是由服务端来通知结果。从这一点来看,同步和阻塞其实描述的是两个不同角度的事情,阻塞和非阻塞指的是客户端等待消息处理时本身的状态,是挂起还是继续干别的。同步和异步指的是对于消息结果是客户端主动获取的,还是由服务端间接推送的。记住这两点关键的区别将有助于我们更好地区分和理解它们。
在Linux系统中,操作系统内核只有一个内核缓冲区。而每个用户程序(进程),有自己独立的缓冲区,叫作进程缓冲区。
所以,用户程序的IO读写程序,在大多数情况下,并没有进行实际的IO操作,而是在进程缓冲区和内核缓冲区之间直接进行数据的交换。
2.1.2详解典型的系统调用流程
前面讲到,用户程序所使用的系统调用read&write,它们不等价于数据在内核缓冲区和磁盘之间的交换。
read把数据从内核缓冲区复制到进程缓冲区,write把数据从进程缓冲区复制到内核缓冲区,具体的流程,如图所示。
图 系统调用read&write的流程
这里以read系统调用为例,先看下一个完整输入流程的两个阶段:
- 等待数据准备好。
- 从内核向进程复制数据.
如果是read一个socket(套接字),那么以上两个阶段的具体处理流程如下:
- 第一个阶段,等待数据从网络中到达网卡。当所等待的分组到达时,它被复制到内核中的某个缓冲区。这个工作由操作系统自动完成,用户程序无感知。
- 第二个阶段,就是把数据从内核缓冲区复制到应用进程缓冲区。
2四种主要的IO模型
服务器端编程,经常需要构造高性能的网络应用,需要选用高性能的IO模型,这也是通关大公司面试必备的知识。本章从最为基础的模型开始,为大家揭秘IO模型。常见的IO模型有四种:
1.同步阻塞IO (Blocking IO)
首先,解释一下这里的阻塞与非阻塞:
阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间执行用户的操作。阻塞指的是用户空间程序的执行状态。传统的IO模型都是同步阻塞IO。在Java中,默认创建的socket都是阻塞的。
其次,解释一下同步与异步:
同步IO,是一种用户空间与内核空间的IO发起方式。同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则反过来,是指系统内核是主动发起IO请求的一方, 用户空间的线程是被动接受方。
2.同步非阻塞 IO (Non-blocking IO)
非阻塞IO,指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间执行用户的操作,即处于非阻塞的状态,与此同时内核会立即返回给用户一个状态值。
简单来说:阻塞是指用户空间(调用线程)一直在等待,而不能干别的事情;非阻塞是指用户空间(调用线程)拿到内核返回的 状态值就返回自己的空间,IO操作可以干就干,不可以干,就去干别的事情。
非阻塞IO要求socket被设置为NONBLOCK。
强调一下,这里所说的NI0(同步非阻塞IO)模型,并非Java的 NIO(NewIO)库。
3. IO多路复用(IO Multiplexing)
即经典的Reactor反应器设计模式,有时也称为异步阻塞IO,Java中的Selector选择器和Linux中的epoll都是这种模型。
4.异步IO (Asynchronous IO)
异步IO,指的是用户空间与内核空间的调用方式反过来。用户空间的线程变成被动接受者,而内核空间成了主动调用者。这有点类似于Java中比较典型的回调模式,用户空间的线程向内核空间注册了各种IO事件的回调函数,由内核去主动调用。
2.1 同步阻塞IO (Blocking IO)
在Java应用程序进程中,默认情况下,所有的socket连接的IO操作都是同步阻塞IO(Blocking IO)。
在阻塞式IO模型中,Java应用程序从IO系统调用开始,直到系统调用返回,在这段时间内,Java进程是阻塞的。返回成功后,应用进程开始处理用户空间的缓存区数据。
同步阻塞IO的具体流程,如图所示。
图 同步阻塞IO的流程
举个例子,在Java中发起一个socket的read读操作的系统调用,流程大致如下:
(1)从Java启动IO读的read系统调用开始,用户线程就进入阻塞状态。
(2)当系统内核收到read系统调用,就开始准备数据。一开始,数据可能还没有到达内核缓冲区(例如,还没有收到一个完整的socket数据包),这个时候内核就要等待。
(3)内核一直等到完整的数据到达,就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如 返回复制到用户缓冲区中的字节数)。
(4)直到内核返回后,用户线程才会解除阻塞的状态,重新运行起来。
总之,阻塞IO的特点是:在内核进行IO执行的两个阶段,用户线程都被阻塞了。
阻塞IO的优点是:应用的程序开发非常简单;在阻塞等待数据期间,用户线程挂起。在阻塞期间,用户线程基本不会占用CPU资源。
阻塞IO的缺点是:一般情况下,会为每个连接配备一个独立的线程;反过来说,就是一个线程维护一个连接的IO操作。在并发量小的情况下,这样做没有什么问题。
但是,当在高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上阻塞IO模型在高并发应用场景下是不可用的。
2.2 同步非阻塞NIO (None Blocking IO)
socket连接默认是阻塞模式,在Linux系统下,可以通过设置将socket变成为非阻塞的模式(Non-Blocking)。使用非阻塞模式的IO读写,叫作同步非阻塞IO(None Blocking IO),简称为NIO模式。
在NIO模型中,应用程序一旦开始IO系统调用,会出现以下两种情况:
(1)在内核缓冲区中没有数据的情况下,系统调用会立即返回,返回一个调用失败的信息。
(2)在内核缓冲区中有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。
同步非阻塞IO的流程,如图所示。
图 同步非阻塞/O的流程
举个例子。发起一个非阻塞socket的read读操作的系统调用,流程如下:
(1)在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为了读取到最终的数据,用户线程需要不断地发起IO系统调用。
(2)内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到的用 户缓冲区的字节数)。
(3)用户线程读到数据后,才会解除阻塞状态,重新运行起来。也就是说,用户进程需要经过多次的尝试,才能保证最终真正读到数据,而后继续执行。
同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。
同步非阻塞IO的优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
同步非阻塞IO的缺点:不断地轮询内核,这将占用大量的CPU时间,效率低下。
总体来说,在高并发应用场景下,同步非阻塞IO也是不可用的。 一般Web服务器不使用这种IO模型。这种IO模型一般很少直接使用,而是在其他IO模型中使用非阻塞IO这一特性。在Java的实际开发中,也不会涉及这种IO模型。
这里说明一下,同步非阻塞IO,可以简称为NIO,但是,它不是Java中的NIO,虽然它们的英文缩写一样,希望大家不要混淆。
Java的NIO(New IO),对应的不是四种基础IO模型中的NIO (None Blocking IO)模型,而是另外的一种模型,叫作IO多路复用模型(IO Multiplexing)。
2.3 IO多路复用模型(IO Multiplexing)
如何避免同步非阻塞IO模型中轮询等待的问题呢?这就是IO多路复用模型。
在IO多路复用模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的系统调用为select/epoll系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统调用。
目前支持IO多路复用的系统调用,有select、epoll等等。select系统调用,几乎在所有的操作系统上都有支持,具有良好的跨平台特性。epoll是在Linux2.6内核中提出的,是select系统调用的Linux增强版本。
在IO多路复用模型中通过select/epoll系统调用,单个应用程序的线程,可以不断地轮询成百上千的socket连接,当某个或者某些socket网络连接有IO就绪的状态,就返回对应的可以执行的读写操作。
举个例子来说明IO多路复用模型的流程。发起一个多路复用IO的read读操作的系统调用,流程如下:
(1)选择器注册。在这种模式中,首先,将需要read操作的目标socket网络连接,提前注册到select/epoll选择器中,Java中对应的选择器类是Selector类。然后,才可以开启整个I0多路复用模型的轮询流程。
(2)就绪状态的轮询。通过选择器的查询方法,查询注册过的所有socket连接的就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列表。当任何一个注册过的socket中的数据准备好了,内核缓冲区有数据(就绪)了,内核就将该socket加入到就绪的列表中。
当用户进程调用了select查询方法,那么整个线程会被阻塞掉。
(3)用户线程获得了就绪状态的列表后,根据其中的socket连接,发起read系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
(4)复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。
IO多路复用模型的流程,如图所示。
图 IO多路复用模型的流程
IO多路复用模型的特点:IO多路复用模型的IO涉及两种系统调用(System Call),另一种是select/epoll(就绪查询),一种是IO操作。IO多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用select/epoll。
和NIO模型相似,多路复用IO也需要轮询。负责select/epoll状态查询调用的线程,需要不断地进行select/epoll轮询,查找出达到IO操作就绪的socket连接。
IO多路复用模型与同步非阻塞IO模型是有密切关系的。对于注册在选择器上的每一个可以查询的socket连接,一般都设置成为同步非阻塞模型。仅是这一点,对于用户程序而言是无感知的。
IO多路复用模型的优点:与一个线程维护一个连接的阻塞IO模式相比,使用select/epoll的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接(Connection)o系统不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。
Java语言的NIO(New IO)技术,使用的就是IO多路复用模型。在Linux系统上,使用的是epoll系统调用。
IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。
如何彻底地解除线程的阻塞,就必须使用异步IO模型。
2.2.4异步IO模型(Asynchronous IO)
异步IO模型(Asynchronous IO,简称为AI0)。AIO的基本流程是:用户线程通过系统调用,向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后,通知用户程序,用户执行后续的业务操作。
在异步IO模型中,在整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
异步IO模型的流程,如图所示。
图 异步IO模型的流程
举个例子。发起一个异步IO的read读操作的系统调用,流程如下:
(1)当用户线程发起了read系统调用,立刻就可以开始去做其他的事,用户线程不阻塞。
(2)内核就开始了I0的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存)。
(3)内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。
(4)用户线程读取用户缓冲区的数据,完成后续的业务操作。
异步I0模型的特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的I0操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。正因为如此,异步I0有的时候也被称为信号驱动IO。
异步I0异步模型的缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。
理论上来说,异步IO是真正的异步输入输出,它的吞吐量高于IO多路复用模型的吞吐量。
就目前而言,Windows系统下通过I0CP实现了真正的异步IO。而在Linux系统下,异步IO模型在2.6版本才引入,目前并不完善,其底层实现仍使用epoll,与IO多路复用相同,因此在性能上没有明显的优势。
大多数的高并发服务器端的程序,一般都是基于Linux系统的。因而,目前这类高并发网络应用程序的开发,大多采用IO多路复用模型。
大名鼎鼎的Netty框架,使用的就是IO多路复用模型,而不是异步IO模型。
2.3通过合理配置来支持百万级并发连接
本章所聚焦的主题,是高并发IO的底层原理。前面已经深入浅出地介绍了高并发IO的模型。但是,即使采用了最先进的模型,如果不进行合理的配置,也没有办法支撑百万级的网络连接并发。
这里所涉及的配置,就是Linux操作系统中文件句柄数的限制。
顺便说下,在生产环境中,大家都使用Linux系统,所以,后续文字中假想的生产操作系统,都是Linux系统。另外,由于大多数同
学使用Windows进行学习和工作,因此,后续文字中假想的开发所用的操作系统都是Windows系统。
在生产环境Linux系统中,基本上都需要解除文件句柄数的限制。原因是,Linux的系统默认值为1024,也就是说,一个进程最多可以接受1024个socket连接。这是远远不够的。
本书的原则是:从基础讲起。
文件句柄,也叫文件描述符。在Linux系统中,文件可分为:普通文件、目录文件、链接文件和设备文件。
文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,它是一个非负整数(通常是小整数),用于指代被打开的文件。所有的I0系统调用,包括socket的读写调用,都是通过文件描述符完成的。
在Linux下,通过调用ulimit命令,可以看到单个进程能够打开的最大文件句柄数量,这个命令的具体使用方法是:
ulimit -n
什么是ulimit命令呢?它是用来显示和修改当前用户进程一些基础限制的命令,-n命令选项用于引用或设置当前的文件句柄数量的限制值。Linux的系统默认值为1024。
默认的数值为1024,对绝大多数应用(例如Apache、桌面应用程序)来说已经足够了。但是,是对于一些用户基数很大的高并发应用,则是远远不够的。一个高并发的应用,面临的并发连接数往往是十万级、百万级、千万级、甚至像腾讯QQ一样的上亿级。
ulimit -n 1000000
在上面的命令中,n的设置值越大,可以打开的文件句柄数量就越大。建议以root用户来执行此命令。
然而,使用ulimit命令来修改当前用户进程的一些基础限制,仅在当前用户环境有效。直白地说,就是在当前的终端工具连接当前shell期间,修改是有效的;一旦断开连接,用户退出后,它的数值就又变回系统默认的1024了。
也就是说,ulimit只能作为临时修改,系统重启后,句柄数量又会恢复为默认值。
如果想永久地把设置值保存下来,可以编辑/etc/rc.local开机启动文件,在文件中添加如下内容:
ulimit -SHn 1000000
增加-S和-H两个命令选项。选项-S表示软性极限值,-H表示硬性极限值。硬性极限是实际的限制,就是最大可以是100万,不能再多了。软性极限是系统警告(Warning)的极限值,超过这个极限值,内核会发出警告。
普通用户通过ulimit命令,可将软极限更改到硬极限的最大设置值。如果要更改硬极限,必须拥有root用户权限。
终极解除Linux系统的最大文件打开数量的限制,可以通过编辑Linux的极限配置文件/etc/security/limits.conf来解决,修改此文件,加入如下内容:
soft nofile 1000000
hard nofile 1000000
soft nofile表示软性极限,hard nofile表示硬性极限。
在使用和安装目前非常火的分布式搜索引擎一一ElasticSearch,就必须去修改这个文件,增加最大的文件句柄数的极限值。
在服务器运行Netty时,也需要去解除文件句柄数量的限制,修改/etc/security/limits.conf文件即可。
边缘触发(Edge Trigger)和条件触发(Level Trigger)
边缘触发(Edge Trigger)和条件触发(Level Trigger)
概述
边缘触发 是指每当状态变化时发生一个io事件;
条件触发 是只要满足条件就发生一个io事件;
详述
int select(int n, fd_set *rd_fds, fd_set *wr_fds, fd_set *ex_fds, struct timeval *timeout);
select用到了fd_set结构,此处有一个FD_SETSIZE决定fd_set的容量,FD_SETSIZE默认1024,可以通过ulimit -n或者setrlimit函数修改之。
int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
作为select的替代品,poll的参数用struct pollfd数组(第一个参数)来取代fd_set,数组大小自己定义,这样的话避免了FD_SETSIZE给程序带来的麻烦。
每次的 select/poll操作,都需要建立当前线程的关心事件列表,并挂起此线程到等待队列中 直到事件触发或者timeout结束,同时select/poll返回后也需要对传入的句柄列表做一次扫描来dispatch。随着连接数增 加,select和poll的性能是严重非线性下降。
epoll(linux), kqueue(freebsd), /dev/poll(solaris):
作为针对select和poll的升级(可以这么理解:)),主要它们做了两件事情
1. 避免了每次调用select/poll时kernel分析参数建立事件等待结构的开销,kernel维护一个长期的事件关注列表,应用程序通过句柄修改这个列表和捕获I/O事件。
2. 避免了select/poll返回后,应用程序扫描整个句柄表的开销,Kernel直接返回具体的事件列表给应用程序。
同时还有两种触发机制:
水平触发(level-triggered,也被称为条件触发)LT: 只要满足条件,就触发一个事件(只要有数据没有被获取,内核就不断通知你)
边缘触发(edge-triggered)ET: 每当状态变化时,触发一个事件
“举个读socket的例子,假定经过长时间的沉默后,现在来了100个字节,这时无论边缘触发和条件触发都会产生一个read ready notification通知应用程序可读。应用程序读了50个字节,然后重新调用api等待io事件。这时水平触发的api会因为还有50个字节可读从 而立即返回用户一个read ready notification。而边缘触发的api会因为可读这个状态没有发生变化而陷入长期等待。 因此在使用边缘触发的api时,要注意每次都要读到socket返回EWOULDBLOCK为止,否则这个socket就算废了。而使用条件触发的api 时,如果应用程序不需要写就不要关注socket可写的事件,否则就会无限次的立即返回一个write ready notification。大家常用的select就是属于水平触发这一类,长期关注socket写事件会出现CPU 100%的毛病。
epoll的优点:
1.支持一个进程打开大数目的socket描述符(FD)
select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显 然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完 美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
2.IO效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的, 但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行 操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的---比如一个高速LAN环境,epoll并不比select/poll有什么效率,相 反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
3.使用mmap加速内核与用户空间的消息传递。
这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。
4.内核微调
这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。 比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小 --- 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手 的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网 卡驱动架构。