网络编程

网络编程

一. 基础概念

1. socket套接字

套接字使用TCP提供了两台计算机之间的通信机制。 客户端程序创建一个套接字,并尝试连接服务器的套接字。当连接建立时,服务器会创建一个 Socket 对象。客户端和服务器现在可以通过对 Socket 对象的写入和读取来进行通信。

2. 同步和异步

同步和异步是针对应用程序和内核的交互而言的,同步指的是用户进程触发IO 操作并等待或者轮询的去查看IO 操作是否就绪,而异步是指用户进程触发IO 操作以后便开始做自己的事情,而当IO 操作已经完成的时候会得到IO 完成的通知。

以银行取款为例:

  • 同步是自己亲自出马持银行卡到银行取钱(使用同步 IO 时,Java 自己处理IO 读写);
  • 异步是委托一小弟拿银行卡到银行取钱,然后给你(使用异步IO 时,Java 将 IO 读写委托给OS 处理,需要将数据缓冲区地址和大小传给OS(银行卡和密码),OS 需要支持异步IO操作API)。

3. 阻塞和非阻塞

阻塞和非阻塞是针对于进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作方法的实现方式,阻塞方式下读取或者写入函数将一直等待,而非阻塞方式下,读取或者写入方法会立即返回一个状态值。

以银行取款为例:

  • 阻塞 : ATM排队取款,你只能等待(使用阻塞IO时,Java调用会一直阻塞到读写完成才返回);
  • 非阻塞 : 柜台取款,取个号,然后坐在椅子上做其它事,等号广播会通知你办理,没到号你就不能去,你可以不断问大堂经理排到了没有,大堂经理如果说还没到你就不能去(使用非阻塞IO时,如果不能读写Java调用会马上返回,当IO事件分发器通知可读写时再继续进行读写,不断循环直到读写完成)

4. 事件分离器

  • Reactor:详见第二章第三节(多路复用IO)
  • Proactor:详见第二章第四节(异步IO)

二. IO模型

1.阻塞IO(blocking IO)

what:阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。

特点:就是在IO执行的等待数据和拷贝数据两个阶段都被block了。

eg:除非特别指定,几乎所有的IO接口 ( 包括socket接口 ) 都是阻塞型的。如在调用send()的同时,这个线程将被阻塞。

缺点:在线程被阻塞期间,线程将无法执行任何运算或响应任何的网络请求。因此采用多线程方案,但多线程会严重占据系统资源,降低系统对外界响应效率,从而考虑使用“线程池”或“连接池”,旨在减少创建和销线程的频率,重复利用池内的线程资源,在一定程度上缓解频繁调用IO接口带来的资源占用。但所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

伪代码

1
2
3
4
5
6
{
// read阻塞
read(socket, buffer);
// 处理buffer
process(buffer);
}

2.非阻塞IO(non-blocking IO)

IO请求时加上O_NONBLOCK一类的标志位,立刻返回,IO没有就绪会返回错误,需要请求进程主动轮询不断发IO请求直到返回正确。

what:用户进程不断主动询问kernel数据准备好了没有,若准备好了则将数据拷贝到了用户内存,否则返回error。

特点:相比于阻塞型接口的显著差异在于在被调用之后立即返回。

eg. Linux下可以通过设置socket使其变为non-blocking。

缺点:循环调用recv()将大幅度推高CPU占用率;此外,在这个方案中recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。

伪代码

1
2
3
4
5
{
// read非阻塞
while(read(socket, buffer) != SUCCESS);
process(buffer);
}

3.多路复用IO(IO multiplexing)

同非阻塞IO本质一样,不过利用了新的select系统调用,由内核来负责本来是请求进程该做的轮询操作。 比非阻塞IO多了一个系统调用开销,但是因为可以支持多路IO,提高了效率。

因其轮询select的线程会被阻塞,所以常被称为异步阻塞IO。

what:也叫事件驱动IO(event driven IO),使用select/epoll函数不断轮询负责所有的socket,当某个socket有数据到达了,就通知用户进程。

特点:单个process可以同时处理多个网络连接的IO,因此只用单线程即可为多客户端提供服务。

缺点:当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询。其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。

幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent库,还有作为libevent替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。

图示

伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
// 注册
select(socket);
// 轮询
while(true) {
// 阻塞
sockets = select();
// 数据到达, 解除阻塞
for(socket in sockets) {
if(can_read(socket)) {
// 数据已到达, 那么socket阻不阻塞无所谓
       read(socket, buffer);
process(buffer);
}
}
}
}

实际上,我们可以给select注册多个socket, 然后不断调用select读取被激活的socket,实现在同一线程内同时处理多个IO请求的效果。

异步方式:把select轮询抽出来放在一个线程里, 用户线程向其注册相关socket或IO请求,等到数据到达时通知用户线程,则可以提高用户线程的CPU利用率。

这其实是Reactor设计模式, 如下图

EventHandler抽象类表示IO事件处理器,可继承EventHandler对事件处理器的行为进行定制。

  • get_handle方法获得文件句柄Handle
  • handle_event方法实现对Handle的操作

Reactor类管理EventHandler的注册、删除。

  • handle_events方法实现了事件循环, 其不断调用阻塞函数select, 只要某个文件句柄被激活(可读/写等),select就从阻塞中返回,handle_events接着调用与文件句柄关联的事件处理器的handle_event进行相关操作。

伪代码

handler_events :

1
2
3
4
5
6
7
8
Reactor::handle_events() {
while(true) {
sockets = select();
for(socket in sockets) {
get_event_handler(socket).handle_event();
}
}
}

功能调用者 :

1
2
3
4
5
6
7
8
9
10
11
12
// 继承EventHandler并重写handle_event()方法
void UserEventHandler::handle_event() {
if(can_read(socket)) {
// 数据已到达, 那么socket阻不阻塞无所谓
read(socket, buffer);
process(buffer);
}
}
// 注册实现的EventHandler子类
{
Reactor.register(new UserEventHandler(socket));
}

4.异步IO(Asynchronous I/O)

不会因为IO操作阻塞,IO操作全部完成才通知请求进程。

what:用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

特点:真正非阻塞,不会对去请求进程产生任何的阻塞。

eg: Linux内核从2.6开始,引入了支持异步响应的IO操作,如aio_read, aio_write。

对比

  • IO多路复用模型中,数据到达内核后通知用户线程,用户线程负责从内核空间拷贝数据;
  • 异步IO模型中,当用户线程收到通知时,数据已经被操作系统从内核拷贝到用户指定的缓冲区内,用户线程直接使用即可。

img

异步IO模型使用了Proactor设计模式实现了这一机制。

img

  • Reactor模式中,用户线程向Reactor对象注册事件对应的事件处理器,然后事件触发时Reactor调用事件处理函数。

  • Proactor模式中,用户线程将AsynchronousOperation(读/写等)、Proactor以及操作完成时的CompletionHandler注册到AsynchronousOperationProcessor。

    AsynchronousOperationProcessor使用Facade模式提供了一组异步API(读/写等)供用户调用. 当用户线程调用异步API后,便继续执行下一步代码. 而此时AsynchronousOperationProcessor会开启独立的内核线程执行异步操作。

    当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。

    异步IO完成时,AsynchronousOperationProcessor将Proactor和CompletionHandler取出,并将IO操作结果和CompletionHandler分发给Proactor,Proactor通知用户线程(即回调先前注册的事件完成处理类的函数handle_event)。

    Proactor一般被实现为单例,以便于集中分发操作完成事件。

伪代码

1
2
3
4
5
6
7
8
9
// 继承CompletionHandler, buffer为用户线程指定的缓冲区
void UserCompletionHandler::handle_event(buffer) {
process(buffer);
}

// 调用异步的read函数
{
aio_read(socket, new UserCompletionHandler);
}

相比于IO多路复用,异步IO并不常用,因为目前操作系统对异步IO的支持并不完善,IO多路复用也基本够用. 有很多做法是用IO多路复用模型模拟异步IO(IO事件触发时不直接通知用户线程,而是将数据读写完毕后放到用户指定的缓冲区中)。 JDK7已经支持了AIO, netty采用过又放弃了, 据说是性能并没有多路复用好.

5 信号驱动IO(Signal driven I/O)

调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。

IO模型的比较

三. IO方式

1.BIO 编程 (Blocking IO,同步阻塞)

一个连接对应一个线程。客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。

what:同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。同时建立好的连接在通讯过程中是同步的,在并发处理效率上比较低。

编程实现:首先在服务端启动一个ServerSocket来监听网络请求,客户端启动Socket发起网络请求,默认情况下ServerSocket回建立一个线程来处理此请求,如果服务端没有线程可用,客户端则会阻塞等待或遭到拒绝。

适用场景:BIO编程方式通常是在JDK1.4版本之前常用的编程方式。BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。

2.NIO 编程(Unblocking IO(New IO),同步非阻塞)

一个请求对应一个线程。使用单线程或少量的多线程,每个连接共用一个线程,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。

what:NIO本身是基于事件驱动思想来完成的,其主要想解决的是BIO的大并发问题,NIO基于Reactor,当socket有流可读或可写入socket时,操作系统会相应的通知引用程序进行处理,应用再将流读取到缓冲区或写入操作系统。也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。

NIO的最重要的地方是当一个连接创建后,不需要对应一个线程,这个连接会被注册到多路复用器上面,所以所有的连接只需要一个线程就可以搞定,当这个线程中的多路复用器进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。

缺点:在NIO的处理方式中,当一个请求来的话,开启线程进行处理,可能会等待后端应用的资源(JDBC连接等),其实这个线程就被阻塞了,当并发上来的话,还是会有BIO一样的问题。

HTTP/1.1出现后,有了Http长连接,这样除了超时和指明特定关闭的http header外,这个链接是一直打开的状态的,这样在NIO处理中可以进一步的进化,在后端资源中可以实现资源池或者队列,当请求来的话,开启的线程把请求和请求数据传送给后端资源池或者队列里面就返回,并且在全局的地方保持住这个现场(哪个连接的哪个请求等),这样前面的线程还是可以去接受其他的请求,而后端的应用的处理只需要执行队列里面的就可以了,这样请求处理和后端应用是异步的.当后端处理完,到全局地方得到现场,产生响应,这个就实现了异步处理。

适用场景:NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。

3.AIO编程(Asynchronous IO,异步非阻塞)

一个有效请求对应一个线程。客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。

what:与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。

编程实现:在JDK1.7中,这部分内容被称作NIO.2,主要在 java.nio.channels 包下增加了下面四个异步通道:

  • AsynchronousSocketChannel
  • AsynchronousServerSocketChannel
  • AsynchronousFileChannel
  • AsynchronousDatagramChannel

其中的read/write方法,会返回一个带回调函数的对象,当执行完读取/写入操作后,直接调用回调函数。

适用场景:AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

多线程编程之线程池

Why

在java中,如果每个请求到达就创建一个新线程,开销是相当大的。在实际使用中,创建和销毁线程花费的时间和消耗的系统资源都相当大,甚至可能要比在处理实际的用户请求的时间和资源要多的多。除了创建和销毁线程的开销之外,活动的线程也需要消耗系统资源。如果在一个jvm里创建太多的线程,可能会使系统由于过度消耗内存或“切换过度”而导致系统资源不足。为了防止资源不足,需要采取一些办法来限制任何给定时刻处理的请求数目,尽可能减少创建和销毁线程的次数,特别是一些资源耗费比较大的线程的创建和销毁,尽量利用已有对象来进行服务。

What

线程池,其实就是一个容纳多个线程的容器,其中的线程可以反复使用,省去了频繁创建线程对象的操作,无需反复创建线程而消耗过多资源。

线程池主要用来解决线程生命周期开销问题和资源不足问题。通过对多个任务重复使用线程,线程创建的开销就被分摊到了多个任务上了,而且由于在请求到达时线程已经存在,所以消除了线程创建所带来的延迟。这样,就可以立即为请求服务,使用应用程序响应更快;另外,通过适当的调整线程中的线程数目可以防止出现资源不足的情况。

How

线程池都是通过线程池工厂创建,再调用线程池中的方法获取线程,再通过线程去执行任务方法。

  • Executors:线程池创建工厂类
  • public static ExecutorServicenewFixedThreadPool(int nThreads):返回线程池对象
  • ExecutorService:线程池类
  • Future<?> submit(Runnable task):获取线程池中的某一个线程对象,并执行
  • Future 接口:用来记录线程任务执行完毕后产生的结果。线程池创建与使用

Example

使用Runnable接口创建线程池:

  • 创建线程池对象
  • 创建 Runnable 接口子类对象
  • 提交 Runnable 接口子类对象
  • 关闭线程池

Test.java 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {
public static void main(String[] args) {
//创建线程池对象 参数5,代表有5个线程的线程池
ExecutorService service = Executors.newFixedThreadPool(5);
//创建Runnable线程任务对象
TaskRunnable task = new TaskRunnable();
//从线程池中获取线程对象
service.submit(task);
System.out.println("----------------------");
//再获取一个线程对象
service.submit(task);
//关闭线程池
service.shutdown();
}
}

TaskRunnable.java 接口文件如下:

1
2
3
4
5
6
7
8
public class TaskRunnable implements Runnable{
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("自定义线程任务在执行"+i);
}
}
}

参考文章

  1. 5种IO模型:https://www.cnblogs.com/findumars/p/6361627.html
  2. BIO与NIO、AIO的区别:https://blog.csdn.net/dreamer23/article/details/80903978
  3. 四种常用IO模型:https://www.cnblogs.com/myJavaEE/p/6721127.html