网络编程-190615整理

网络编程

  • 网络编程
    • 术语与技术
      • Handle,句柄
      • I/O 多路复用技术
        • I/O多路复用概述
        • 用户进程和内核
        • select和recvfrom
          • select
          • recvfrom
        • 阻塞、非租塞
        • 适用场景
        • 总结
        • 一些代码
        • Java NIO关键类以及函数解释
          • ByteBuffer
            • flip()
            • clear() 与 compact()
            • compact()
          • SocketChannel
            • 打开SocketChannel
            • 关闭SocketChannel
            • 检查是否建立连接 connect() 和 finishConnect()
            • 非阻塞模式与选择器
          • ServerSocketChannel
          • Selector
          • SelectionKey
            • interest集合
            • ready集合
            • Channel + Selector
            • 附加的对象(可选)
          • 通过Selector选择通道
            • select()方法
            • selectedKeys()方法
            • wakeUp()方法
            • close()方法
          • 一个完整实例
    • 一些工具
    • 参考

术语与技术

Handle,句柄

句柄(handle),有多种意义,其中第一种是指程序设计,第二种是指Windows编程。现在大部分都是指程序设计/程序开发这类。

  • 第一种解释:句柄是一种特殊的智能指针 。当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,就要使用句柄。
  • 第二种解释:整个Windows编程的基础。一个句柄是指使用的一个唯一的整数值,即一个4字节(64位程序中为8字节)长的数值,来标识应用程序中的不同对象和同类中的不同的实例,诸如,一个窗口,按钮,图标,滚动条,输出设备,控件或者文件等。应用程序能够通过句柄访问相应的对象的信息,但是句柄不是指针,程序不能利用句柄来直接阅读文件中的信息。如果句柄不在I/O文件中,它是毫无用处的。 句柄是Windows用来标志应用程序中建立的或是使用的唯一整数,Windows大量使用了句柄来标识对象。

I/O 多路复用技术

首页

当我们要编写一个echo服务器程序的时候,需要对用户从标准输入键入的交互命令做出响应。在这种情况下,服务器必须响应两个相互独立的I/O事件:
1)网络客户端发起网络连接请求
2)用户在键盘上键入命令行。
我们先等待哪个事件呢?没有哪个选择是理想的。如果在acceptor中等待一个连接请求,我们就不能响应输入的命令。类似地,如果在read中等待一个输入命令,我们就不能响应任何连接请求。针对这种困境的一个解决办法就是I/O多路复用技术。基本思路就是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。--《UNIX网络编程》
我们以书中的这段描述来引出我们要讲述的I/O多路复用技术。

I/O多路复用概述

首页

I/O多路复用,I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。串起来理解就是很多个网络I/O复用一个或少量的线程来处理这些连接。现在大部分讲述I/O多路复用的文章用到的上面这张图是《UNIX网络编程》一书的。那么这也是当前我们理解I/O多路复用技术的基础知识。从这张图里面我们GET到哪些点呢?
个人理解有:
1、怎么区分应用进程与内核
2、有两次系统调用分别是select和recvfrom
3、两次系统调用进程都阻塞
4、等待哪些数据准备好
下面我们逐一阐述。

用户进程和内核

首页


根据网络OSI七层模型和网际网协议族的同比,我们可以知道这里说的用户进程和内核是以传输层为分割线,传输层以上(不包括)是指用户进程,传输层以下(包括)是指内核。上三层,web客户端比如浏览器、web服务器这些都属于应用层,里面跑的程序则是应用进程。下四层处理所有的通信细节,发送数据,等待确认,给无序到达的数据排序等等。这四层也是通常作为操作系统内核的一部分提供。由此可见图1中说的系统调用的地方正是第四层和第五层之间的位置。
为了理解用户进程和内核,再来看一张图,网络数据流向图。也清晰的标明了用户进程和内核的位置。值得注意的一点是客户与服务器之间的信息流在其中一端是向下通过协议栈的,跨越网络后,在另一端是向上通过协议栈的。这张图描述的是局域网内,如果是在广域网那么就是通过很多个路由器承载实际数据流。

select和recvfrom

首页

select

首页
理解了select就抓住了I/O多路复用的精髓,对应的操作系统中调用的则是系统的select函数,该函数会等待多个I/O事件(比如读就绪,写)的任何一个发生,并且只要有一个网络事件发生,select线程就会执行。如果没有任何一个事件发生则阻塞。我们在下面小节中会重点讲述。函数如下:

#include
#include
int select(int maxfdpl,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);

从这个函数的定义中的参数,我们能够看出它描述的是,当调用select的时候告知内核对那些事件(读就绪,写)感兴趣以及等待多长时间。

为了方便我们理解select调用,可以参照下面这张图,是jdk的基于I/O多路复用技术的NIO实现。重点在于理解Selector复用器。

大致代码如下

ServerSocketChannel serverChannel = ServerSocketChannel.open();// 打开一个未绑定的serversocketchannel   
Selector selector = Selector.open();// 创建一个Selector
serverChannel .configureBlocking(false);//设置非阻塞模式
serverChannel .register(selector, SelectionKey.OP_ACCEPT);//将ServerSocketChannel注册到Selector

while(true) {
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;
  Set selectedKeys = selector.selectedKeys();
  Iterator keyIterator = selectedKeys.iterator();
  while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {//连接就绪
        // a connection was established with a remote server.
    } else if (key.isReadable()) {//读就绪
        // a channel is ready for reading
    } else if (key.isWritable()) {//写就绪
        // a channel is ready for writing
    }
    keyIterator.remove();
  }
}

recvfrom

首页

recvfrom一般用于UDP协议中,但是如果在TCP中connect函数调用后也可以用。用于从(已连接)套接口上接收数据,并捕获数据发送源的地址。也就是我们本文中以及书中说的真正的I/O操作。

阻塞、非租塞

首页

 

这张图可以看出阻塞式I/O、非阻塞式I/O、I/O复用、信号驱动式I/O他们的第二阶段都相同,也就是都会阻塞到recvfrom调用上面就是图中“发起”的动作。异步式I/O两个阶段都要处理。这里我们重点对比阻塞式I/O(也就是我们常说的传统的BIO)和I/O复用之间的区别。
阻塞式I/O和I/O复用,两个阶段都阻塞,那区别在哪里呢?就在于第三节讲述的Selector,虽然第一阶段都是阻塞,但是阻塞式I/O如果要接收更多的连接,就必须创建更多的线程。I/O复用模式下在第一个阶段大量的连接统统都可以过来直接注册到Selector复用器上面,同时只要单个或者少量的线程来循环处理这些连接事件就可以了,一旦达到“就绪”的条件,就可以立即执行真正的I/O操作。这就是I/O复用与传统的阻塞式I/O最大的不同。也正是I/O复用的精髓所在。
从应用进程的角度去理解始终是阻塞的,等待数据和将数据复制到用户进程这两个阶段都是阻塞的。这一点我们从应用程序是可以清楚的得知,比如我们调用一个以I/O复用为基础的NIO应用服务。调用端是一直阻塞等待返回结果的。
从内核的角度等待Selector上面的网络事件就绪,是阻塞的,如果没有任何一个网络事件就绪则一直等待直到有一个或者多个网络事件就绪。但是从内核的角度考虑,有一点是不阻塞的,就是复制数据,因为内核不用等待,当有就绪条件满足的时候,它直接复制,其余时间在处理别的就绪的条件。这也是大家一直说的非阻塞I/O。实际上是就是指的这个地方的非阻塞。
当我们阅读《UNIX网络编程》(第三版)一书的时候。P124,6.2.3小节中“而不是阻塞在真正的I/O系统调用上”这里的阻塞是相对内核来说的。P127,6.2.7小节“因为其中真正的I/O操作(recvfrom)将阻塞进程”这里的阻塞是相对用户进程来说的。明白了这两点,理解起来就不矛盾了,而且一通到底!

适用场景

首页

当服务程序需要承载大量TCP链接的时候,比如我们的消息推送系统,IM通讯,web聊天等等,在我们已经理解Selector原理的情况下,知道使用I/O复用可以用少量的线程处理大量的链接。I/O多路复用技术以事件驱动编程为基础。它运行在单一进程上下文中,因此每个逻辑流都能访问该进程的全部地址空间,这样在流之间共享数据变得很容易。

总结

首页

我们通常说的NIO大多数场景下都是基于I/O复用技术的NIO,比如jdk中的NIO,当然Tomcat8以后的NIO也是指的基于I/O复用的NIO。注意,使用NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。如果放到线上环境,网络情况在有时候并不稳定的情况下,这种基于I/O复用技术的NIO的优势就是传统BIO不可同比的了。那么使用select的优势在于我们可以等到网络事件就绪,那么用少量的线程去轮询Selector上面注册的事件,不就绪的不处理,就绪的拿出来立即执行真正的I/O操作。这个使得我们就可以用极少量的线程去HOLD住大量的连接。

一些代码

首页

while (it.hasNext()) {//遍历每个事件 
try{ 
SelectionKey key = (SelectionKey)it.next(); //先得到这个学生的编号key 
 
//判断是新生报道还是老生问问题 
if ((key.readyOps() & SelectionKey.OP_ACCEPT) 
== SelectionKey.OP_ACCEPT) { 
//这是招生老师的Key说明是新生注册,先找到招生老师,再由招生老师找到新生,就可以给新生注册学号了 
ServerSocketChannel serverChanel = (ServerSocketChannel)key.channel(); //通过key把学校和老师找到了
//从serverSocketChannel中创建出与客户端的连接socketChannel 有了老师才有学生,不可能我教计算机的,来一个想学李小龙的都让他报名 
SocketChannel sc = serverChanel.accept(); //学生报名成功 
sc.configureBlocking( false ); 
// 把新连接注册到选择器,新生被接收后给注册个新学号 
SelectionKey newKey = sc.register( selector, 
SelectionKey.OP_READ ); //注册学号成功,并分配学生的权限 
it.remove(); //新生注册任务完成了,呵呵 
System.out.println( "Got connection from "+sc ); 
}else 
//读客户端数据的事件,此时有客户端发数据过来,客户端事件 这是老学生来问问题了。 
if((key.readyOps() & SelectionKey.OP_READ)== SelectionKey.OP_READ){ 
// 读取数据 ,接受学生的问题 
SocketChannel sc = (SocketChannel)key.channel(); //通过学号知道是谁问的问题 
 
//下面接受问题 
int bytesEchoed = 0; 
while((bytesEchoed = sc.read(echoBuffer))> 0){ 
System.out.println("bytesEchoed:"+bytesEchoed); 
} 
echoBuffer.flip(); 
System.out.println("limet:"+echoBuffer.limit()); 
byte [] content = new byte[echoBuffer.limit()]; 
echoBuffer.get(content); 
String result=new String(content); 
doPost(result,sc); //相应老师会去做回答的,细节自己去写吧 
echoBuffer.clear(); 
it.remove(); //任务完成,记得上面也是一样,要remove掉,否则下一次又来一次任务,就死循环了 
} 
}catch(Exception e){} 
} 
}

Java NIO关键类以及函数解释

首页

ByteBuffer

flip()

在Java NIO编程中,对缓冲区操作常常需要使用java.nio.Buffer中的flip()方法。Buffer中的flip()方法涉及到Buffer中的Capacity、Position、Limit三个概念。

  • Capacity:在读写模式下都是固定的,就是我们分配的缓冲大小。
  • Position:类似于读写指针,表示当前读(写)到什么位置。
  • Limit:在写模式下表示最多能写入多少数据,此时和Capacity相同。在读模式下表示最多能读多少数据,此时和缓存中的实际数据大小相同。在写模式下调用 flip() 方法,那么limit就设置为了position当前的值(即当前写了多少数据),postion会被置为0,以表示读操作从缓存的头开始读。

也就是说调用flip之后,读写指针指到缓存头部,并且设置了最多只能读出之前写入的数据长度(而不是整个缓存的容量大小)。

实例代码(借用Java编程思想P552的代码):

package cn.com.newcom.ch18;

import java.io.FileInputStream;

import java.io.FileOutputStream;

import java.io.RandomAccessFile;

import java.nio.ByteBuffer;

import java.nio.channels.FileChannel;

/**

* 获取通道

*/

public class GetChannel {

private static final int SIZE = 1024;

public static void main(String[] args) throws Exception {

// 获取通道,该通道允许写操作

FileChannel fc = new FileOutputStream("data.txt").getChannel();

// 将字节数组包装到缓冲区中

fc.write(ByteBuffer.wrap("Some text".getBytes()));

// 关闭通道

fc.close();

// 随机读写文件流创建的管道

fc = new RandomAccessFile("data.txt", "rw").getChannel();

// fc.position()计算从文件的开始到当前位置之间的字节数

System.out.println("此通道的文件位置:" + fc.position());

// 设置此通道的文件位置,fc.size()此通道的文件的当前大小,该条语句执行后,通道位置处于文件的末尾

fc.position(fc.size());

// 在文件末尾写入字节

fc.write(ByteBuffer.wrap("Some more".getBytes()));

fc.close();

// 用通道读取文件

fc = new FileInputStream("data.txt").getChannel();

ByteBuffer buffer = ByteBuffer.allocate(SIZE);

// 将文件内容读到指定的缓冲区中

fc.read(buffer);

buffer.flip();// 此行语句一定要有

while (buffer.hasRemaining()) {

System.out.print((char) buffer.get());

}

fc.close();

}

}

注意:

buffer.flip();一定得有,如果没有,就是从文件最后开始读取的,
当然读出来的都是byte=0时候的字符。通过buffer.flip()这个语句,
就能把buffer的当前位置更改为buffer缓冲区的第一个位置。

clear() 与 compact()

首页
一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成。

  • clear()
    如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未真正被清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

  • compact()
    如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。
    如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。
    compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。

compact()

首页

SocketChannel

Java NIO中的SocketChannel是一个连接到TCP网络套接字的通道。可以通过以下2种方式创建SocketChannel:

  • 打开一个SocketChannel并连接到互联网上的某台服务器。
  • 一个新连接到达ServerSocketChannel时,会创建一个SocketChannel。

打开SocketChannel

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));

关闭SocketChannel

socketChannel.close();

检查是否建立连接 connect() 和 finishConnect()

如果SocketChannel在非阻塞模式下,此时调用connect(),该方法可能在连接建立之前就返回了。为了确定连接是否建立,可以调用finishConnect()的方法。

非阻塞模式与选择器

非阻塞模式与选择器搭配会工作的更好,通过将一或多个SocketChannel注册到Selector,可以询问选择器哪个通道已经准备好了读取,写入等。Selector与SocketChannel的搭配使用会在后面详讲。

ServerSocketChannel

Selector

首页

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

  • 为什么使用Selector?
    仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。
    但是,需要记住,现代的操作系统和CPU在多任务方面表现的越来越好,所以多线程的开销随着时间的推移,变得越来越小了。实际上,如果一个CPU有多个内核,不使用多任务可能是在浪费CPU能力。不管怎么说,关于那种设计的讨论应该放在另一篇不同的文章中。在这里,只要知道使用Selector能够处理多个通道就足够了。
  • Selector的创建
    通过调用Selector.open()方法创建一个Selector,如下:
    Selector selector = Selector.open();
  • 向Selector注册通道
    为了将Channel和Selector配合使用,必须将channel注册到selector上。通过SelectableChannel.register()方法来实现,如下:
    channel.configureBlocking(false);
    SelectionKey key = channel.register(selector,
    Selectionkey.OP_READ);
    与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。

注意register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:

  • Connect
  • Accept
  • Read
  • Write

通道触发了一个事件意思是该事件已经就绪。所以,某个channel成功连接到另一个服务器称为“连接就绪”。一个server socket channel准备好接收新进入的连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”。等待写数据的通道可以说是“写就绪”。

这四种事件用SelectionKey的四个常量来表示:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

SelectionKey

首页
在上一小节中,当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。这个对象包含了一些你感兴趣的属性:

interest集合

就像向Selector注册通道一节中所描述的,interest集合是你所选择的感兴趣的事件集合。可以通过SelectionKey读写interest集合,像这样:

int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept  = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;

可以看到,用“位与”操作interest 集合和给定的SelectionKey常量,可以确定某个确定的事件是否在interest 集合中。

ready集合

ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先访问这个ready set。Selection将在下一小节进行解释。可以这样访问ready集合:

int readySet = selectionKey.readyOps();

可以用像检测interest集合那样的方法,来检测channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

Channel + Selector

从SelectionKey访问Channel和Selector很简单。如下:

Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();

附加的对象(可选)

可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

还可以在用register()方法向Selector注册Channel的时候附加对象。如:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

通过Selector选择通道

首页
一旦向Selector注册了一或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道。

select()方法

  • int select()
    select()阻塞到至少有一个通道在你注册的事件上就绪了。
  • int select(long timeout)
    select(long timeout)和select()一样,除了最长会阻塞timeout毫秒(参数)。
  • int selectNow()
    selectNow()不会阻塞,不管什么通道就绪都立刻返回(译者注:此方法执行非阻塞的选择操作。如果自从前一次选择操作后,没有通道变成可选择的,则此方法直接返回零。)。

select()方法返回的int值表示有多少通道已经就绪。亦即,自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。

selectedKeys()方法

一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:

Set selectedKeys = selector.selectedKeys();

当像Selector注册Channel时,Channel.register()方法会返回一个SelectionKey 对象。这个对象代表了注册到该Selector的通道。可以通过SelectionKey的selectedKeySet()方法访问这些对象。

可以遍历这个已选择的键集合来访问就绪的通道。如下:

Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }
    keyIterator.remove();
}

这个循环遍历已选择键集中的每个键,并检测各个键所对应的通道的就绪事件。

注意每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。

SelectionKey.channel()方法返回的通道需要转型成你要处理的类型,如ServerSocketChannel或SocketChannel等。

wakeUp()方法

某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。

如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。

close()方法

用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。

一个完整实例

Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while(true) {
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;
  Set selectedKeys = selector.selectedKeys();
  Iterator keyIterator = selectedKeys.iterator();
  while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }
    keyIterator.remove();
  }
}

一些工具

首页

  • TCP/UDP 调试工具

参考

  • 书籍《Unix 网络编程》
  • 资料:Java NIO并发网络编程

你可能感兴趣的:(网络编程,网络编程,网络,Sockect,NIO,IO)