TCP/IP协议
要编写计算机网络编程,首先要了解这些程序相互通信所使用的协议。
IP层接收由更低层(网络接口层例如以太网设备驱动程序)发来的数据包,并把该数据包发送到更高层---TCP或UDP层;相反,IP层也把从TCP或UDP层接收来的数据包传送到更低层。IP数据包是不可靠的,因为IP并没有做任何事情来确认数据包是否按顺序发送的或者有没有被破坏,IP数据包中含有发送它的主机的地址(源地址)和接收它的主机的地址(目的地址)。
TCP是面向连接的通信协议,通过三次握手建立连接,通讯完成时要拆除连接,由于TCP是面向连接的所以只能用于端到端的通讯。TCP提供的是一种可靠的数据流服务,采用“带重传的肯定确认”技术来实现传输的可靠性。TCP还采用一种称为“滑动窗口”的方式进行流量控制,所谓窗口实际表示接收能力,用以限制发送方的发送速度。
以下是使用TCP协议在网络中通信的图(截自《unix网络编程》)
说到滑动窗口,我们有必要看看滑动窗口的机制,这有可能会造成socket的死锁,我们先看看TCP包的结构:
TCP的Window是一个16bit位字段,它代表的是窗口的字节容量,也就是TCP的标准窗口最大为2^16-1=65535个字节。对于TCP会话的发送方,任何时候在其发送缓存内的数据都可以分为4类:
1、已经发送并得到对端ACK的
2、已经发送但还未收到对端ACK的
3、未发送但对端允许发送的
4、未发送且对端不允许发送
TCP是双工的协议,会话的双方都可以同时接收、发送数据。TCP会话的双方都各自维护一个“发送窗口”和一个“接收窗口”。其中各自的“接收窗口”大小取决于应用、系统、硬件的限制(TCP传输速率不能大于应用的数据处理速率)。各自的“发送窗口”则要求取决于对端通告的“接收窗口”,要求相同。
所以很简单的看到一端接受窗口已满,所以另一端处于未发送而且对端不允许发送状态,只要想继续发送数据就会造成线程阻塞,而死锁也就很轻易就会造成了,用程序表达一下就是:
//client out.write(); ..... //server那边接受窗口已满,等待调用read()读取数据,线程阻塞 out.write(); in.read(); //server out.write(); ..... //client那边接受窗口已满,等待调用read()读取数据,线程阻塞 out.write(); in.read();
可以看见双方都在等待对方调用read( )而造成了死锁,尽管我们在程序中有写要读取数据。解决这种死锁也很简单,把write( )和read( )分别写入两个不同线程就可以避免这种死锁,当然也可以换成NIO。
socket
网上已经有很多socekt编程的离职啦,这里就不贴了,以下是socket通信图(截自《unix网络编程》)
让我们看看简单的new Socket(serverHost,serverPort) java为我们做了什么:
/** * socket 属性有: * private boolean created = false; * private boolean bound = false; * private boolean connected = false; * private boolean closed = false; * private Object closeLock = new Object(); * private boolean shutIn = false; * private boolean shutOut = false; * SocketImpl impl; * private boolean oldImpl= false; * private static SocketImplFactory factory = null; */ public Socket(String paramString, int paramInt) throws UnknownHostException, IOException { this(paramString != null ? new InetSocketAddress(paramString, paramInt) : new InetSocketAddress(InetAddress.getByName(null), paramInt), (SocketAddress)null, true); } //最后要用的构造方法 private Socket(SocketAddress paramSocketAddress1, SocketAddress paramSocketAddress2, boolean paramBoolean) throws IOException { //factory不为null的时候用factory生成impl,factory为null的时候制定SocksSocketImpl为impl //此时我们的impl为SocksSocketImpl setImpl(); if (paramSocketAddress1 == null) { throw new NullPointerException(); } try { //调用impl的create方法,此时由impl父类AbstractPlainSocketImpl完成 //就是生成此socket的文件描述符(下文会大概说明文件描述符) createImpl(paramBoolean); if (paramSocketAddress2 != null) { bind(paramSocketAddress2); } if (paramSocketAddress1 != null) { //最后调用了SocksSocketImpl的connect方法 connect(paramSocketAddress1); } } catch (IOException localIOException) { close(); throw localIOException; } } // SocksSocketImpl的connect方法,方法太长只把关键几步贴出 protected void connect(SocketAddress paramSocketAddress, int paramInt){ //利用localProxy获取客户端的地址,并生成port //根据传入的sockeAdress判断是ipv4还是ipv6 this.server = ((InetSocketAddress)localProxy.address()).getHostString(); this.serverPort = ((InetSocketAddress)localProxy.address()).getPort(); if (((localProxy instanceof SocksProxy)) && (((SocksProxy)localProxy).protocolVersion() == 4)) { this.useV4 = true; } //把SocksSocketImpl中的输入输出流与生成的文件描述符绑定 privilegedConnect(this.server, this.serverPort, remainingMillis(l1)); //利用输出流按照TCP/IP协议把数据写入指定的文件(用文件描述符绑定的,这里我们可以认为是网卡) connectV4((InputStream)localObject1, localBufferedOutputStream, localInetSocketAddress, l1); }
这里我们出现了一个新的叫做文件描述符的东西,让我们来看看到底是什么:
在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。
最重要的是进程维护的文件描述符(图片来自网上):
好了我们利用文件描述符把数据写到了网卡,那么网卡又是怎么传送数据的呢?网卡驱动程序,将IP包添加14字节的MAC包头,构成MAC包。MAC包中含有发送端和接收端的MAC地址信息。既然是驱动程序创建的MAC包头信息,当然可以随便输入地址信息的,主机伪装就是这么实现的。驱动程序将MAC包拷贝到网卡芯片内部的缓存区,就算完事了。有网卡芯片接手处理。网卡芯片对MAC包,再次封装成物理帧,添加头部同步信息和CRC校验。然后丢到网线上,就完成一个IP报文的发送。所有挂接到本网线的网卡都可以看到该物理帧。也就是说我们可以认为我们利用文件描述符把数据写进了网卡的内部缓存区。
NIO
前面已经讲过socket编程了,我们发现会很容易死锁,所以现在我们来看看他的替代方案NIO,我们在NIO中用非阻塞模式,就是把socket中的一些阻塞方法,改为立即返回。比如connect()方法,在unix系统中立即返回一个EINPROGRESS错误,但是3次握手依然进行,所以在处理连接的时候要先判断是否完成了握手,java中为SocketChannel.isConnectionPending( )。那再让我们看看源码是怎样做的。
/* * SocketChannel.open() */ public static SocketChannel open() throws IOException { return SelectorProvider.provider().openSocketChannel(); } //最后调用了这个方法 public SocketChannel openSocketChannel() throws IOException { //这里最重要的是为我们生成了一个文件描述符 return new SocketChannelImpl(this); } /* * SocketChannel.connect() */ //同样方法太长只贴出关键部分 public boolean connect(SocketAddress paramSocketAddress) throws IOException { //主要是设置socketChannelImpl中的open属性为false //还优雅的实现了线程中断唤醒,详情http://www.oschina.net/question/138146_26027 begin(); ......... /* *虽然没看过connect0()源码,但是我们能大胆猜测,connect0()发起了3次握手请求 *如果没有发起握手,返回的应该是-3 *如果发起了握手,根据下面的代码返回的不是-3,结束循环 */ for (;;) { //???这个不知道是怎么回事。。不过我们还是能看出是socket中需要的ip ??? = localInetSocketAddress.getAddress(); if (???.isAnyLocalAddress()) { ??? = InetAddress.getLocalHost(); } //这里调用了native方法connect0(),需要阅读jvm源码,但是C还需要重新捡起来 j = Net.connect(this.fd, ???, localInetSocketAddress.getPort()); if ((j != -3) || (!isOpen())) { break; } } ......... synchronized (this.stateLock) { this.remoteAddress = localInetSocketAddress; if (j > 0) { this.state = 2; if (isOpen()) { this.localAddress = Net.localAddress(this.fd); } return true; } //如果是非阻塞模式 if (!isBlocking()) { //这边state=1表示正在进行连接 //state=2表示已经建立好连接 //isConnectionPending()就是基于state的值来判断的 this.state = 1; } else if (!$assertionsDisabled) { throw new AssertionError(); } } } //如果未完成3次握手,则继续完成,依旧是部分代码 public boolean finishConnect() throws IOException{ if (!isBlocking()) { for (;;) { //很遗憾checkConnect也是native方法 //但是大致我们猜测就是检验握手是否完成吧 i = checkConnect(this.fd, false, this.readyToConnect); if ((i != -3) || (!isOpen())) { break; } } } }
当然把阻塞的方法改为非阻塞这些是不够的,所以NIO还为我们提供了IO复用,就是select机制。下面我们来看看Selector.select(long timeout)是怎么是实现的。下面是 Channel,SelectionKey,Selector之间的三角关系:
//Selector.select()很遗憾最后调用的还是native方法poll0() private int poll() throws IOException { return poll0(WindowsSelectorImpl.this.pollWrapper.pollArrayAddress, Math.min(WindowsSelectorImpl.this.totalChannels, 1024), this.readFds, this.writeFds, this.exceptFds, WindowsSelectorImpl.this.timeout); } /* *又到了我们大胆猜测的时候了 *从参数我们可以看出,我们传入了对读,写,异常感兴趣的文件描述符数组 *估计poll0是轮询我们感兴趣的事件是否准备就绪,并返回就绪的数量 */
NIO本身也不会提高速度,只是他在一定程度上能够降低并发数,从而提高CPU效率。还有以上好多关于JVM源码都是我猜想中的实现,观众老爷轻拍——
——暂时.完