Socket通信,主要是基于TCP协议的通信。本文从Socket通信(代码实现)、多线程并发、以及TCP协议相关原理方面 介绍 阻塞Socket通信一些知识。
本文从服务器端的视角,以“Echo Server”程序为示例,描述服务器如何处理客户端的连接请求。Echo Server的功能就是把客户端发给服务器的数据原封不动地返回给客户端。
第一种方式是单线程处理方式:服务器的处理方法如下:
1 public void service(){
2 while (true) {
3 Socket socket = null;
4 try {
5 socket = serverSocket.accept();
6 System.out.println("new connection accepted " + socket.getInetAddress() + ":" + socket.getPort());
7 BufferedReader br = getBufferReader(socket);//获得socket输入流,并将之包装成BufferedReader
8 PrintWriter pw = getWriter(socket);//获得socket输出流,并将之包装成PrintWriter
9 String msg = null;
10 while ((msg = br.readLine()) != null) {
11
12 pw.println(echo(msg));//服务端的处理逻辑,将client发来的数据原封不动再发给client
13 pw.flush();
14 if(msg.equals("bye"))//若client发送的是 "bye" 则关闭socket
15 break;
16 }
17 } catch (IOException e) {
18 e.printStackTrace();
19 } finally {
20 try{
21 if(socket != null)
22 socket.close();
23 }catch(IOException e){e.printStackTrace();}
24 }
25 }
26 }
上面用的是while(true)循环,这样,Server不是只接受一次Client的连接就退出,而是不断地接收Client的连接。
1)第5行,服务器线程执行到accept()方法阻塞,直至有client的连接请求到来。
2)当有client的请求到来时,就会建立socket连接。从而在第8、9行,就可以获得这条socket连接的输入流和输出流。输入流(BufferedReader)负责读取client发过来的数据,输出流(PrintWriter)负责将处理后的数据返回给Client。
下面来详细分析一下建立连接的过程:
Client要想成功建立一条到Server的socket连接,其实是受很多因素影响的。其中一个就是:Server端的“客户连接请求队列长度”。它可以在创建ServerSocket对象由构造方法中的 backlog 参数指定:JDK中 backlog参数的解释是: requested maximum length of the queue of incoming connections.
public ServerSocket(int port, int backlog) throws IOException {
this(port, backlog, null);
}
看到了这个:incoming commections 有点奇怪,因为它讲的是“正在到来的连接”,那什么又是incoming commections 呢?这个就也TCP建立连接的过程有关了。
TCP建立连接的过程可简述为三次握手。第一次:Client发送一个SYN包,Server收到SYN包之后回复一个SYN/ACK包,此时Server进入一个“中间状态”--SYN RECEIVED 状态。
这可以理解成:Client的连接请求已经过来了,只不过还没有完成“三次握手”。因此,Server端需要把当前的请求保存到一个队列里面,直至当Server再次收到了Client的ACK之后,Server进入ESTABLISHED状态,此时:serverSocket 从accpet() 阻塞状态中返回。也就是说:当第三次握手的ACK包到达Server端后,Server从该请求队列中取出该连接请求,同时Server端的程序从accept()方法中返回。
那么这个请求队列长度,就是由 backlog 参数指定。那这个队列是如何实现的呢?这个就和操作系统有关了
此外,也可以看出:服务器端能够接收的最大连接数 也与 这个请求队列有关。对于那种高并发场景下的服务器而言,首先就是请求队列要足够大;其次就是当连接到来时,要能够快速地从队列中取出连接请求并建立连接,因此,执行建立连接任务的线程最好不要阻塞。
现在来分析一下上面那个:单线程处理程序可能会出现的问题:
服务器始终只有一个线程执行accept()方法接受Client的连接。建立连接之后,又是该线程处理相应的连接请求业务逻辑,这里的业务逻辑是:把客户端发给服务器的数据原封不动地返回给客户端。
显然,这里一个线程干了两件事:接受连接请求 和 处理连接(业务逻辑)。好在这里的处理连接的业务逻辑不算复杂,如果对于复杂的业务逻辑 而且 有可能在执行业务逻辑过程中还会发生阻塞的情况时,那此时服务器就再也无法接受新的连接请求了。
第二种方式是:一请求一线程的处理模式:
1 public void service() {
2 while (true) {
3 Socket socket = null;
4 try {
5 socket = serverSocket.accept();//接受client的连接请求
6 new Thread(new Handler(socket)).start();//每接受一个请求 就创建一个新的线程 负责处理该请求
7 } catch (IOException e) {
8 e.printStackTrace();
9 }
10 finally {
11 try{
12 if(socket != null)
13 socket.close();
14 }catch(IOException e){e.printStackTrace();}
15 }
16 }
17 }
再来看Handler的部分实现:Handler是一个implements Runnable接口的线程,在它的run()里面处理连接(执行业务逻辑)
1 class Handler implements Runnable{
2 Socket socket;
3 public Handler(Socket socket) {
4 this.socket = socket;
5 }
6
7 @Override
8 public void run() {
9 try{
10 BufferedReader br = null;
11 PrintWriter pw = null;
12 System.out.println("new connection accepted " + socket.getInetAddress() + ":" + socket.getPort());
13
14 br = getBufferReader(socket);
15 pw = getWriter(socket);
16
17 String msg = null;
18 while((msg = br.readLine()) != null){
19 pw.println(echo(msg));
20 pw.flush();
21 if(msg.equals("bye"))
22 break;
23 }
24 }catch(IOException e){
25 e.printStackTrace();
26 }
27 }
从上面的单线程处理模型中看到:如果线程在执行业务逻辑中阻塞了,服务器就不能接受用户的连接请求了。
而对于一请求一线程模型而言,每接受一个请求,就创建一个线程来负责该请求的业务逻辑。尽管,这个请求的业务逻辑执行时阻塞了,只要服务器还能继续创建线程,那它就还可以继续接受新的连接请求。此外,负责建立连接请求的线程 和 负责处理业务逻辑的线程分开了。业务逻辑执行过程中阻塞了,“不会影响”新的请求建立连接。
显然,如果Client发送的请求数量很多,那么服务器将会创建大量的线程,而这是不现实的。有以下原因:
1)创建线程是需要系统开销的,线程的运行系统资源(内存)。因此,有限的硬件资源 就限制了系统中线程的数目。
2)当系统中线程很多时,线程的上下文开销会很大。比如,请求的业务逻辑的执行是IO密集型任务,经常需要阻塞,这会造成频繁的上下文切换。
3)当业务逻辑处理完成之后,就需要销毁线程,如果请求量大,业务逻辑又很简单,就会导致频繁地创建销毁线程。
那能不能重用已创建的线程? ---这就是第三种方式:线程池处理。
第三种方式是线程池的处理方式:
1 public class EchoServerThreadPool {
2 private int port = 8000;
3 private ServerSocket serverSocket;
4 private ExecutorService executorService;
5 private static int POOL_SIZE = 4;//每个CPU中线程拥有的线程数
6
7 public EchoServerThreadPool()throws IOException {
8 serverSocket = new ServerSocket(port);
9 executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * POOL_SIZE);
10 System.out.println("server start");
11 }
12
13 public void service(){
14 while(true){
15 Socket socket = null;
16 try{
17 socket = serverSocket.accept();//等待接受Client连接
18 executorService.execute(new Handler(socket));//将已经建立连接的请求交给线程池处理
19 }catch(IOException e){
20 e.printStackTrace();
21 }
22 }
23 }
24 public static void main(String[] args)throws IOException{
25 new EchoServerThreadPool().service();
26 }
27 }
采用线程池最大的优势在于“重用线程”,有请求任务来了,从线程池中取出一个线程负责该请求任务,任务执行完成后,线程自动归还到线程池中,而且java.util.concurrent包中又给出了现成的线程池实现。因此,这种方式看起来很完美,但还是有一些问题是要注意的:
1)线程池有多大?即线程池里面有多少个线程才算比较合适?这个要根据具体的业务逻辑来分析,而且还得考虑面对的使用场景。一个合理的要求就是:尽量不要让CPU空闲下来,即CPU的复用率要高。如果业务逻辑是经常会导致阻塞的IO操作,一般需要设置 N*(1+WT/ST)个线程,其中N为可用的CPU核数,WT为等待时间,ST为实际占用CPU运算时间。如果业务逻辑是CPU密集型作业,那么线程池中的线程数目一般为N个或N+1个即可,因为太多了会导致CPU切换开销,太少了(小于N),有些CPU核就空闲了。
2)线程池带来的死锁问题
线程池为什么会带来死锁呢?在JAVA 1.5 之后,引入了java.util.concurrent包。线程池则可以通过如下方式实现:
ExecutorService executor = Executors.newSingleThreadExecutor();
//ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(task);// task implements Runnable
executor.shutdown();
Executors可以创建各种类型的线程池。如果创建一个缓存的线程池:
ExecutorService executor = Executors.newCachedThreadPool();
对于高负载的服务器而言,在缓存线程池中,被提交的任务没有排成队列,而是直接交给线程执行。也就是说:只要来一个请求,如果线程池中没有线程可用,服务器就会创建一个新的线程。如果线程已经把CPU用完了,此时还再创建线程就没有太大的意义了。因此,对于高负载的服务器而言,一般使用的是固定数目的线程池(来自Effective Java)
主要有两种类型的死锁:①线程A占有了锁X,等待锁Y,而线程B占用了锁Y,等待锁X。因此,向线程池提交任务时,要注意判断:提交了的任务(Runnable对象)会不会导致这种情况发生?
②线程池中的所有线程在执行各自的业务逻辑时都阻塞了,它们都需要等待某个任务的执行结果,而这个任务还在“请求队列”里面未提交!
3)来自Client的请求实在是太多了,线程池中的线程都用完了(已无法再创建新线程)。此时,服务器只好拒绝新的连接请求,导致Client抛出:ConnectException。
4)线程泄露
导致线程泄露的原因也很多,而且还很难发觉,网上也有很多善于线程池线程泄露的问题。比如说:线程池中的线程在执行业务逻辑时抛异常了,怎么办?是不是这个工作线程就异常终止了?那这样,线程池中可用的线程数就少了一个了?看一下JDK ThreadPoolExecutor 线程池中的线程执行任务的过程如下:
try {
while (task != null || (task = getTask()) != null) {
w.lock();
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
从上面源码看出:线程执行出异常后是由 afterExecute(task, thrown)
另外一种引起线程泄露的情况就是:线程池中的工作线程在执行业务逻辑时,一直阻塞下去了。那这也意味着这个线程基本上不干活了,这就影响了线程池中实际可用的线程数目。如何所有的线程都是这种情况,那也无法向线程池提交任务了。
到这里,阻塞通信的三种模式都已经介绍完毕了。
TCP连接 对 应用层协议(比如 HTTP协议)会产生哪些影响?
主要从以下几个方面描述TCP协议对应用层协议的影响:(结合JAVA网络编程中的 具体SOcket类的 相关参数分析)
1)最大段长度MSS
TCP协议是提供可靠连接的,在建立连接的过程中,会协商一些参数,比如MSS。TCP传输的数据是流,把流截成一段段的报文进行传输,MSS是 每次传输TCP报文的 最大数据分段。
为什么需要MSS呢?如果传输的报文太大,则需要在IP层进行分片,分成了若干片的报文在传输过程中任何一片丢失了,整个报文都得重传。重传直接影响了网络效率。因此,在建立连接时就协商(SYN包)底层的数据链路层最大能传递多大的报文(比如以太网的MTU=1500),然后在传输层(TCP)就对数据进行分段,尽量避免TCP传输的数据在IP层分片。
而对于上层应用而言(比如HTTP协议),它只管将数据写入缓冲区,但其实它写入的数据在TCP层其实是被分段发送的。当目的主机收到所有的分段之后,需要重组分段。因此,就会出现所谓的HTTP粘包问题。
2)TCP连接建立过程的“三次握手”
“三次握手”的大概流程如下:
Client发送一个SYN包,Server返回一个SYN/ACK包,然后Client再对 SYN/ACK 包进行一次确认ACK。在对 SYN/ACK 进行确认时,Client就可以向Server端 发送实际的数据了。这种利用ACK确认时顺带发送数据的方式 可以 减少 Client与Server 之间的报文交换。
3)TCP“慢启动”的拥塞控制
什么是“慢启动”呢?因为TCP连接是可靠连接,具有拥塞控制的功能。如果不进行拥塞控制,网络拥堵了导致容易丢包,丢包又得重传,就很难保证可靠性了。
新建立的TCP连接而言,它不能立马就发送很多报文,而是:先发送 1个报文,等待对方确认;收到确认后,就可以一次发送2个报文了,再等待对方确认;收到确认后,就一次可以发送4个报文了.....每次可发送的报文数依次增加(指数级增加,当然不会一直增加下去),这个过程就是“打开拥塞窗口”。
那这个慢启动特性有何影响呢?
一般而言,就是“老的”TCP连接 比 新建立的 TCP连接有着更快的发送速度。因为,新的TCP连接有“慢启动”啊。而“老的”TCP连接可能一次允许发送多个报文。
因此,对于HTTP连接而言,选择重用现有连接既可以减少新建HTTP连接的开销,又可以重用老的TCP连接,立即发送数据。
HTTP重用现有的连接,在HTTP1.0的 Connection头部设置"Keep-Alive"属性。在HTTP1.1版本中,默认是打开持久连接的,可参考HTTP1.1中的 persistent 参数。
4)发送数据时,先收集待发送的数据,让发送缓冲区满了之后再发送的Nagle算法
对于一条Socket连接而言,发送方有自己的发送缓冲区。在JAVA中,由java.net.SocketOptions类的 SO_SNFBUF 属性指定。可以调用setSendBufferSize方法来设置发送缓冲区(同理接收缓冲区)
public synchronized void setSendBufferSize(int size)
throws SocketException{
if (!(size > 0)) {
throw new IllegalArgumentException("negative send size");
}
if (isClosed())
throw new SocketException("Socket is closed");
getImpl().setOption(SocketOptions.SO_SNDBUF, new Integer(size));
}
那什么是Negle算法呢?
假设每次发送的TCP分段只包含少量的有效数据(比如1B),而TCP首部加上IP首部至少有40B,每次为了发送1B的数据都要带上一个40B的首部,显然网络利用率是很低的。
因为,Negle算法就是:发送方的数据不是立即就发送,而是先放在缓冲区内,等到缓冲区满了再发送(或者所发送的所有分组都已经返回了确认了)。说白了,就是先把数据“聚集起来”,分批发送。
Negale算法对上层应用会有什么影响呢?
对小批量数据传输的时延影响很大。比如 网络游戏 中的实时捕获 玩家的位置。玩家位置变了,也许只有一小部分数据发送给 服务器,若采用Negale算法,发送的数据被缓冲起来了,服务器会迟迟接收不到玩家的实时位置信息。因此,Negale算法适合于那种大批量数据传输的场景。
因此,SocketOptions类的 TCP_NODELAY 属性用来设置 在TCP连接中是否启用 Negale算法。
public void setTcpNoDelay(boolean on) throws SocketException {
if (isClosed())
throw new SocketException("Socket is closed");
getImpl().setOption(SocketOptions.TCP_NODELAY, Boolean.valueOf(on));
}
5)在发送数据时捎带确认的延迟确认算法
比如,Server在接收到了Client发送的一些数据,但是Server并没有立即对这些数据进行确认。而是:当Server有数据需要发送到Client时,在发送数据的同时 捎带上 对前面已经接收到的数据的确认。(这其实也是尽量减少Server与Client之间的报文量,毕竟:每发一个报文,是有首部开销的。)
这种方式会影响到上层应用的响应性。可能会对HTTP的请求-响应模式产生很大的时延。
6)TCP的 KEEP_ALIVE
这个在JDK源码中解释的非常好了。故直接贴上来:
/**
* When the keepalive option is set for a TCP socket and no data
* has been exchanged across the socket in either direction for
* 2 hours (NOTE: the actual value is implementation dependent),
* TCP automatically sends a keepalive probe to the peer. This probe is a
* TCP segment to which the peer must respond.
* One of three responses is expected:
* 1. The peer responds with the expected ACK. The application is not
* notified (since everything is OK). TCP will send another probe
* following another 2 hours of inactivity.
* 2. The peer responds with an RST, which tells the local TCP that
* the peer host has crashed and rebooted. The socket is closed.
* 3. There is no response from the peer. The socket is closed.
*
* The purpose of this option is to detect if the peer host crashes.
*
* Valid only for TCP socket: SocketImpl
当TCP连接设置了KEEP-ALIVE时,如果这条socket连接在2小时(视情况而定)内没有数据交换,然后就会发一个“探测包”,以判断对方的状态。
然后,等待对方发送这个探测包的响应。一共会出现以上的三种情况,并根据出现的情况作出相应的处理。
①对方(peer)收到了正常的 ACK,说明一切正常,上层应用并不会注意到这个过程(发送探测包的过程)。再等下一个2个小时时继续探测连接是否存活。
②对方返回一个RST包,表明对方已经crashed 或者 rebooted,socket连接关闭。
③未收到对方的响应,socket连接关闭。
这里需要注意的是:在HTTP协议中也有一个KEEP-ALIVE
7)TCP连接关闭时的影响
TCP关闭连接有“四次挥手”,主动关闭连接的一方会有一个 TIME_WAIT 状态。也就是说,在Socket的close()方法执行后,close()方法立即返回了,但是底层的Socket连接并不会立即关闭,而是会等待一段时间,将剩余的数据都发送完毕再关闭连接。可以用SocketOptions的 SO_LINGER 属性来控制sockect的关闭行为。
看JDK中 SO_LINGER的解释如下:
/**
* Specify a linger-on-close timeout. This option disables/enables
* immediate return from a <B>close()</B> of a TCP Socket. Enabling
* this option with a non-zero Integer <I>timeout</I> means that a
* <B>close()</B> will block pending the transmission and acknowledgement
* of all data written to the peer, at which point the socket is closed
* <I>gracefully</I>. Upon reaching the linger timeout, the socket is
* closed <I>forcefully</I>, with a TCP RST. Enabling the option with a
* timeout of zero does a forceful close immediately. If the specified
* timeout value exceeds 65,535 it will be reduced to 65,535.
* <P>
* Valid only for TCP: SocketImpl
*
* @see Socket#setSoLinger
* @see Socket#getSoLinger
*/
public final static int SO_LINGER = 0x0080;
因此,当调用Socket类的 public void setSoLinger(boolean on, int linger)设置了 linger 时间后,执行 close()方法不会立即返回,而是进入阻塞状态。
然后,Socket会 等到所有的数据都已经确认发送了 peer 端。(will block pending the transmission and acknowledgement)【第四次挥手时client 发送的ACK到达了Server端】
或者:经过了 linger 秒之后,强制关闭连接。(forcefully)
那为什么需要一个TIME_WAIT时延呢?即:执行 close()方法 时需要等待一段时间再 真正关闭Socket?这也是“四次挥手”时,主动关闭连接的一方会 持续 TIME_WAIT一段时间(一般是2MSL大小)
①确保“主动关闭端”(Client端)最后发送的ACK能够成功到达“被动关闭端”(Server端)
因为,如何不能确保ACK是否成功到达Server端的话,会影响Server端的关闭。假设最后第四次挥手时 Client 发送给 Server的ACK丢失了,若没有TIME_WAIT,Server会认为是自己FIN包没有成功发送给Client(因为Server未收到ACK啊),就会导致Server重传FIN,而不能进入 closed 状态。
②旧的TCP连接包会干扰新的TCP连接包,导致新的TCP连接收到的包乱序。
若没有TIME_WAIT,本次TCP连接(为了更好的阐述问题,记本次TCP连接为TCP_连接1)断开之后,又立即建立新的一条TCP连接(TCP_连接2)。
TCP_连接1 发送的包 有可能在网络中 滞留了。而现在又新建了一条 TCP_连接2 ,如果滞留的包(滞留的包是无效的包了,因为TCP_连接1已经关闭了) 又 重新到达了 TCP_连接2,由于 滞留的包的(源地址,源端口,目的地址,目的端口)与 TCP_连接2 中发送的包 是一样的,因此会干扰 TCP_连接2 中的包(序号)。
如果有TIME_WAIT,由于TIME_WAIT的长度是 2MSL。因此,TCP_连接1中的滞留的包,经过了2MSL时间之后,已经失效了。就不会干扰新的TCP_连接2了。
此外,这也是为什么在Linux中,你Kill了某个连接进程之后,又立即重启连接进程,会报 端口占用错误,因为在底层,其实它的端口还未释放。