关于Socket高并发的原理介绍及使用Apache Mina带来线上的问题分析

Posted 架构师之旅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了关于Socket高并发的原理介绍及使用Apache Mina带来线上的问题分析相关的知识,希望对你有一定的参考价值。

今天给大家分享一个线上出现的问题,顺便普及一下关于Socket通信的一些常识

上周在线上出现了一个很低级的问题,但是正是这个低级的问题引起了我的兴趣,其实所谓的低级是因为配置文件配置错了,原本线上是为每个客户端设置了一个席位,就说是客户端的配置内容是不同的,但是由于部署的人员将两个客户端席位设置的一样,这时候连接服务端的时候会出现问题,服务端的设置的策略是同一时刻只能有一个席位在线,接下来就开始了“一出好戏”----2个客户端开始抢占服务器,不断的进行“互踢”,因为客户端设置了断线重连。

下面我们先来看一下socket通信的基本流程

1、服务器创建监听socket
2、与对外服务的端口号绑定
3、开始listen监听上面设置的端口
4、客户端连接到服务器对应的port
5、服务器accept为新的客户端产生新的socket
6、基于这个新的socket与客户端交换数据。

7、客户端socket关闭

这个流程是基础的网络交互过程,相信开发过网络应用的同学都清楚,这里采用的是短链接,每次通信后关闭连接,不会保持。那么如果同一个时刻有大量的客户端连接上来,服务端能处理吗?

带着这个问题,我们来学习一下操作系统关于Socket建立的相关处理,这里只是简单了解。重点是引出FD概念。

socket和文件系统紧密相关,我们可以通过文件系统的open、read、write和close等操作socket。


关于Socket高并发的原理介绍及使用Apache Mina带来线上的问题分析

从上图我们可以看出sys_socket是socket相关函数的总入口。

当应用程序使用socket()创建一个socket时,会执行sys_socket,其定义如下

 1asmlinkage long sys_socket(int family, int type, int protocol)
2
{
3    int retval;
4    struct socket *sock;
5    retval = sock_create(family, type, protocol, &sock);//创建socket
6    if (retval < 0)
7        goto out;
8    retval = sock_map_fd(sock);//分配一个未使用的文件描述符fd,并将socket和fd建立联系
9    if (retval < 0)
10        goto out_release;
11out:
12    /* It may be already another descriptor 8) Not kernel problem. */
13    return retval;
14out_release:
15    sock_release(sock);
16    return retval;
17
18}

上面我们可以看到这个语句

1 retval = sock_map_fd(sock);

其中函数sock_map_fd(sock),这个我们就不展开了,该方法体有一行代码

1fd = get_unused_fd();//分配一个未使用的fd

这个就是建立真正的socket的FD语句,从此我们可以看出来,其中每个socket都是有一个FD与之对应的,在Linux操作系统里一切皆文件,那么这个FD的数量是多少也就决定了socket能建立多少,FD会受到系统内存的影响,一般情况下1G的内存可以创建10万个socket,依次叠加2G就是20万。这里操作系统将网络连接与文件系统进行了连接,在进行数据发送读取就和操作文件一样,大家现在知道为啥socket数量创建的数量有限了吧,因为这个会消耗操作系统的资源,所以一般我们使用完FD都会进行释放,防止有资源泄露,无法回收的情况。

这里补充一下FD概念

内核(kernel)利用文件描述符(File Descriptor)来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。

上面的内容可以说是一系统要做到"高并发"能够达到“高”的一个前提条件。

接下来我们引入另外一个概念,就是IO多路复用,这个关系到一个并发的问题,我们知道比如进行文件操作,都会进行IO流的读写,但是对文件的操作一般是阻塞的,在JDK1.4以后引入了NIO,增加了非阻塞的方式,前面我们说对网络数据的操作,也是类似与文件的操作,也可以进行阻塞和非阻塞的模式。这里我们后面在具体说明,这里还有一个关键因素,就是操作系统如何处理IO的多路复用的问题,如果底层处理不好,我们上层应用一样会达不到高效能。

IO多路复用定义
先了解一下2种方式

所谓阻塞方式block,顾名思义,就是进程或是线程执行到这些函数时必须等待某个事件的发生,如果事件没有发生,进程或线程就被阻塞,函数不能立即返回。


所谓非阻塞方式non-block,就是进程或线程执行此函数时不必非要等待事件的发生,一旦执行肯定返回,以返回值的不同来反映函数的执行情况,如果事件发生则与阻塞方式相同,若事件没有发生,则返回一个代码来告知事件未发生,而进程或线程继续执行,所以效率较高。

IO多路复用允许应用在多个文件描述符上阻塞,并在某一个可以读写时通知, 一般遵循下面的设计原则:

  1. IO多路复用:任何FD准备好IO时进行通知

  2. 在FD就绪前进行睡眠。

  3. 唤醒:哪个准备好了

  4. 在不阻塞的情况下处理所有IO就绪的FD

  5. 返回第一步

Linux下提供了三种IO多路复用方案,select、poll和epoll。

由于时间篇幅关系,下面我们简单介绍一下每一种多路复用方案的优缺点

select IO 多路复用:

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

1、 单个进程可监视的fd数量被限制,即能监听端口的大小有限。

      一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

2、 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:

       当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。

3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

poll多路复用:

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

2、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll IO多路复用:

上面的两种方式中,每次调用都需要所有被监听的文件描述符,内核必须遍历所有的文件描述符,当文件描述符变得很大,这里的遍历就会成为瓶颈。epoll将监听注册从实际监听中分离出来,完成了真正的事件等待。

epoll是linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

epoll特点:

1.epoll和select和poll的调用接口上的不同。

select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

2.使用mmap加速内核与用户空间的消息传递。

对于select和poll函数的系统与内核每次调用时的数据拷贝:epoll是通过内核与用户空间mmap同一块内存实现的,在epoll_ctl函数中:每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

3.调用后不需轮询判断描述符事件是否就绪。

对于select和poll函数每次调用后轮询检测事件是否发生:epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果)。

4.监视描述符没有个数上限。

epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,注:在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

5.IO效率不随FD数目增加而线性下降。

传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是“活跃”的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对“活跃”的socket进行操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。只有“活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会。

拓展:系统维护一颗红黑树(平衡搜索二叉树:稳定)存储监视描述符,和一张链表存储就绪的描述符。当每次注册或修改,删除新的文件描述符到epoll句柄中时,就会增加一个描述符到这课红黑树中(增删改查简单),当返回时检测链表上是否有节点,有节点则拷贝到用户传给它的那个描述符数组中。

由此可知,我们如果要开发高并发的网络通信程序,使用selector这种模式是不行的,因为这个有数量的限制,但后面2中模式是可以的,除了底层的支撑外,我们程序还需要使用到NIO,或者是AIO,如果是阻塞IO,性能也不会达到最佳。

好了,到目前为止我们已经对IO复用和socket有了一定的了解。那么我们开始剖析前面的问题。

在实际项目中,我们引用了一个Apache开源的网络框架,名字MINA,它是一个能够帮助用户开发高性能和高伸缩性网络应用程序的框架。

在实际使用过程中也很简单,我们举个例子

比如先建立一个服务端

 1    NiosocketAcceptor acceptor = new NioSocketAcceptor();
2
3        // Prepare the service configuration.
4        if (USE_CUSTOM_CODEC) {
5            acceptor.getFilterChain()
6                    .addLast(
7                            "codec",
8                            new ProtocolCodecFilter(
9                                    new SumUpProtocolCodecFactory(true)));
10        } else {
11            acceptor.getFilterChain().addLast(
12                    "codec",
13                    new ProtocolCodecFilter(
14                            new ObjectSerializationCodecFactory()));
15        }
16        acceptor.getFilterChain().addLast("logger"new LoggingFilter());
17
18        acceptor.setHandler(new ServerSessionHandler());
19        acceptor.bind(new InetSocketAddress(SERVER_PORT));
20
21        System.out.println("Listening on port " + SERVER_PORT);

然后建立一个客户端

 1   NioSocketConnector connector = new NioSocketConnector();
2      connector.setConnectTimeoutMillis(CONNECT_TIMEOUT);
3             if (USE_CUSTOM_CODEC) {
4                 connector.getFilterChain().addLast(
5                         "codec",
6                         new ProtocolCodecFilter(
7                                 new SumUpProtocolCodecFactory(false)));
8             } else {
9                 connector.getFilterChain().addLast(
10                         "codec",
11                         new ProtocolCodecFilter(
12                                 new ObjectSerializationCodecFactory()));
13             }
14             connector.getFilterChain().addLast("logger"new LoggingFilter());
15
16connector.setHandler(new ClientSessionHandler(values));
17             IoSession session;
18             for (;;) {
19                 try {
20                     ConnectFuture future = connector.connect(new InetSocketAddress(
21                             HOSTNAME, PORT));
22                     future.awaitUninterruptibly();
23                     session = future.getSession();
24                     break;
25                 } catch (RuntimeIoException e) {
26                     System.err.println("Failed to connect.");
27                     e.printStackTrace();
28                     Thread.sleep(5000);
29                 }
30             }
31        session.getCloseFuture().awaitUninterruptibly();
32      connector.dispose();

这样我们启动客户端和服务端就能进行数据交换了

我们线上的客户端和服务端是采用长连接异步的通讯方式,就是需要保持心跳的,当我们席位重复互踢时,后面的一个会替代前一个登录上的客户端,这个时候服务端会断开与前一个客户端的连接。

我们设置了每隔30s会自动检测系统是否在线,除了保持心跳额外开启了一个线程进行检测。因为检测到到Iosession已经关闭了会话,这个时候重新走了一次客户端连接服务端的流程,就是上面这段代码,这里只是举例子,真实代码类似,有一句代码出了重要问题,因为我们是长连接,每次建立通讯后,不会执行下面这行代码,

  connector.dispose();

否在就是短链接了,只有在程序连接不上去的时候进行了Dispose操作。今天进行了一个简单的实验,如果你使用了连接但是没有dispose,后果还是很严重的。

下面我们看一下没有dispose的客户端不断和服务端进行连接的结果

服务端

关于Socket高并发的原理介绍及使用Apache Mina带来线上的问题分析

客户端

关于Socket高并发的原理介绍及使用Apache Mina带来线上的问题分析

客户端我们可以看到他是在不断的建立本地的socket连接,每次执行

NioSocketConnector connector = new NioSocketConnector();就会建立很多IP4的回环网络通信,自己和自己进行通讯,这些额外的端口建立耗费了巨大的系统资源,过一会我们看一下客户端,已经建立了相当大的连接,这一部分连接的用途有待进一步考察,它们是做什么用的?

因此,这里第一个问题,如果你采用的是短链接一定要及时释放着NioSocketConnector。

但我遇到的问题并不是释放这个这么简单,通过代码改写,每次session被close掉了以后,会去手动再次dispose()一下,因为想着是服务端主动断开连接,Mina应该会帮我们断开Connector才对的,但是随着互踢的次数增多,系统的FD文件数量,通过查看进程开启的的fd可以知道是在不断的增加的,如果是Linux可以通过lsof -p <processid> |wc -l 统计。一般在linux,为了防止进程过度的消耗系统资源,都会进行fd数量的限制,我们通过命令ulimit -HSn,H指定了硬性大小,S指定了软性大小,n表示设定单个进程最大的打开文件句柄数量。系统一般不建议超过4096。随着时间增加,我们程序的fd数量不一会就已经超过了4096,再次新建连接就会出现下面的异常

 1     2018 - 08 - 31   12 : 09 : 48  -org.apache.mina.core.service.SimpleIoProcessorPool.<init>(SimpleIoProcessorPool.java: 197 )  
2     Failed to create a new  instance of org.apache.mina.transport.socket.nio.NioProcessor: null   
3    java.lang.reflect.InvocationTargetException  
4            at sun.reflect.GeneratedConstructorAccessor110.newInstance(Unknown Source)  
5            at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:27 )  
6            at java.lang.reflect.Constructor.newInstance(Constructor.java:513 )  
7            at org.apache.mina.core.service.SimpleIoProcessorPool.<init>(SimpleIoProcessorPool.java:180 )  
8            at org.apache.mina.core.service.SimpleIoProcessorPool.<init>(SimpleIoProcessorPool.java:112 )  
9            at org.apache.mina.core.polling.AbstractPollingIoConnector.<init>(AbstractPollingIoConnector.java:93 )  
10            at org.apache.mina.transport.socket.nio.NioSocketConnector.<init>(NioSocketConnector.java:56 )  
11            at com.develop.webplatform.funnel.client.JobClient.sendMessage(JobClient.java:39 )  
12            at com.develop.webplatform.funnel.client.JobClient.sendJob(JobClient.java:126 )  
13            at com.develop.webplatform.funnel.extend.JobExecRemotelyBySocket.execJobByTask(JobExecRemotelyBySocket.java:66 )  
14            at com.develop.webplatform.funnel.JobManager.execJobByTask(JobManager.java:27 )  
15            at com.develop.webplatform.quartz.job.TaskJob.executeInternal(TaskJob.java:38 )  
16            at org.springframework.scheduling.quartz.QuartzJobBean.execute(QuartzJobBean.java:86)  
17            at org.quartz.core.JobRunShell.run(JobRunShell.java:223 )  
18            at org.quartz.simpl.SimpleThreadPool$WorkerThread.run(SimpleThreadPool.java:549 )  
19    Caused by: org.apache.mina.core.RuntimeIoException: Failed to open a selector.  
20            at org.apache.mina.transport.socket.nio.NioProcessor.<init>(NioProcessor.java:61 )  
21            ... 15  more  
22    Caused by: java.io.IOException: Too many open files  
23            at sun.nio.ch.IOUtil.initPipe(Native Method)  
24            at sun.nio.ch.EPollSelectorImpl.<init>(EPollSelectorImpl.java:49 )  
25            at sun.nio.ch.EPollSelectorProvider.openSelector(EPollSelectorProvider.java:18 )  
26            at java.nio.channels.Selector.open(Selector.java:209 )  
27            at org.apache.mina.transport.socket.nio.NioProcessor.<init>(NioProcessor.java:59 )  
28            ... 15  more 

意味着操作系统已经不允许当前进程创建更多的文件句柄,所以我们就无法再次连接,为了解决这个问题,我们对源码进行了研究,发现每次新建连接都去

1NioSocketConnector connector = new NioSocketConnector();

执行了这句代码,问题就是处在这里。因为用的nio,这段代码,其实每次都会去底层新建一个sockchannel,然后调用select.open(),将channel注册到select上,那么就会消耗系统资源。因此我们尝试将这段代码改成单例模式,每次只有一个connector,然后每次连接

1 ConnectFuture future = connector.connect(new InetSocketAddress( HOSTNAME, PORT));

这样,问题就解决,每次统计fd数量最多上升到200,就会自动回收,但数量维持在100以上,估计就是上面mina自身开了很多回环IP端口通信使用的。

因此,在实际开发项目的过程中,大家需要时刻注意,第一,需要释放的资源及时回收,如果等JAVA自身的垃圾回收机制,可能来不及,毕竟系统资源很宝贵;第二,对于某些对象,是否需要频繁的创建,如果能使用单例,尽量用单例,这个时候想起Spring强大的容器管理能力。


✦ ✦ ✦ ✦ ✦ ✦ ✦ ✦


喜欢我们的会点赞,爱我们的会分享!


架构师之旅
 
TravelWithFrame

企业级架构、系统开发架构、Web架构、大规模分布式、高可用高性能架构研究探讨、结合互联网应用技术的动态扩展架构,讨论各类中间件如ActiveMqZookeeperDubboKafka,OpenStack,GlusterFs,Ceph,SpringCloudnginx关注java、C++python、node.js、shell、plsql等开发语言,敢于探索,关注最新IT资讯。



(关注ID:TravelWithFrame)

(如有侵权,请联系我们删除)


以上是关于关于Socket高并发的原理介绍及使用Apache Mina带来线上的问题分析的主要内容,如果未能解决你的问题,请参考以下文章

Apache服务安装及工作模式介绍

高并发编程-07-JDK提供的原子类操作及原理

Apache服务的安装及工作模式介绍

linux中高并发socket最大连接数的优化

技术分享| Linux高并发踩过的坑及性能优化

tornado异步原理——高并发