您还有心跳吗?超时机制分析

问题描述

在C/S模式中,有时我们会长时间保持一个连接,以避免频繁地建立连接,但同时,一般会有一个超时时间,在这个时间内没发起任何请求的连接会被断开,以减少负载,节约资源。并且该机制一般都是在服务端实现,因为client强制关闭或意外断开连接,server端在此刻是感知不到的,如果放到client端实现,在上述情况下,该超时机制就失效了。本来这问题很普通,不太值得一提,但最近在项目中看到了该机制的一种糟糕的实现,故在此深入分析一下。

问题分析及解决方案

服务端一般会保持很多个连接,所以,一般是创建一个定时器,定时检查所有连接中哪些连接超时了。此外我们要做的是,当收到客户端发来的数据时,怎么去刷新该连接的超时信息?

最近看到一种实现方式是这样做的:

[code lang=”java”]
public class Connection {
private long lastTime;
public void refresh() {
lastTime = System.currentTimeMillis();
}

public long getLastTime() {
return lastTime;
}
//……
}
[/code]

在每次收到客户端发来的数据时,调用refresh方法。

然后在定时器里,用当前时间跟每个连接的getLastTime()作比较,来判定超时:

[code lang=”java”]
public class TimeoutTask  extends TimerTask{
public void run() {
long now = System.currentTimeMillis();
for(Connection c: connections){
if(now – c.getLastTime()> TIMEOUT_THRESHOLD)
;//timeout, do something
}
}
}
[/code]

看到这,可能不少读者已经看出问题来了,那就是内存可见性问题,调用refresh方法的线程跟执行定时器的线程肯定不是一个线程,那run方法中读到的lastTime就可能是旧值,即可能将活跃的连接判定超时,然后被干掉。

有读者此时可能想到了这样一个方法,将lastTime加个volatile修饰,是的,这样确实解决了问题,不过,作为服务端,很多时候对性能是有要求的,下面来看下在我电脑上测出的一组数据,测试代码如下,供参考

[code lang=”java”]
public class PerformanceTest {
private static long i;
private volatile static long vt;
private static final int TEST_SIZE = 10000000;

public static void main(String[] args) {
long time = System.nanoTime();
for (int n = 0; n < TEST_SIZE; n++)
vt = System.currentTimeMillis();
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
i = System.currentTimeMillis();
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
synchronized (PerformanceTest.class) {
}
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
vt++;
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
vt = i;
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
i = vt;
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
i++;
System.out.println(-time + (time = System.nanoTime()));
for (int n = 0; n < TEST_SIZE; n++)
i = n;
System.out.println(-time + (time = System.nanoTime()));
}
}
[/code]

测试一千万次,结果是(耗时单位:纳秒,包含循环本身的时间):
238932949       volatile写+取系统时间
144317590       普通写+取系统时间
135596135       空的同步块(synchronized)
80042382        volatile变量自增
15875140        volatile写
6548994         volatile读
2722555         普通自增
2949571         普通读写

从上面的数据看来,volatile写+取系统时间的耗时是很高的,取系统时间的耗时也比较高,跟一次无竞争的同步差不多了,接下来分析下如何优化该超时时机。

首先:同步问题是肯定得考虑的,因为有跨线程的数据操作;另外,取系统时间的操作比较耗时,能否不在每次刷新时都取时间?因为刷新调用在高负载的情况下很频繁。如果不在刷新时取时间,那又该怎么去判定超时?

我想到的办法是,在refresh方法里,仅设置一个volatile的boolean变量reset(这应该是成本最小的了吧,因为要处理同步问题,要么同步块,要么volatile,而volatile读在此处是没什么意义的),对时间的掌控交给定时器来做,并为每个连接维护一个计数器,每次加一,如果reset被设置为true了,则计数器归零,并将reset设为false(因为计数器只由定时器维护,所以不需要做同步处理,从上面的测试数据来看,普通变量的操作,时间成本是很低的),如果计数器超过某个值,则判定超时。 下面给出具体的代码:

[code lang=”java”]
public class Connection {
int count = 0;
volatile boolean reset = false;
public void refresh() {
if (reset == false)
reset = true;
}
}

public class TimeoutTask extends TimerTask {
public void run() {
for (Connection c : connections) {
if (c.reset) {
c.reset = false;
c.count = 0;
} else if (++c.count >= TIMEOUT_COUNT)
;// timeout, do something
}
}
}
[/code]

代码中的TIMEOUT_COUNT 等于超时时间除以定时器的周期,周期大小既影响定时器的执行频率,也会影响实际超时时间的波动范围(这个波动,第一个方案也存在,也不太可能避免,并且也不需要多么精确)。

代码很简洁,下面来分析一下。

reset加上了volatile,所以保证了多线程操作的可见性,虽然有两个线程都对变量有写操作,但无论这两个线程怎么穿插执行,都不会影响其逻辑含义。

再说下refresh方法,为什么我在赋值语句上多加了个条件?这不是多了一次volatile读操作吗?我是这么考虑的,高负载下,refresh会被频繁调用,意味着reset长时间为true,那么加上条件后,就不会执行写操作了,只有一次读操作,从上面的测试数据来看,volatile变量的读操作的性能是显著优于写操作的。只不过在reset为false的时候,多了一次读操作,但此情况在定时器的一个周期内最多只会发一次,而且对高负载情况下的优化显然更有意义,所以我认为加上条件还是值得的。

最后提及一下,我有点完美主义,自认为上面的方案在我当前掌握的知识下,已经很漂亮了,如果你发现还有可优化的地方,或更好的方案,希望能分享。
————————————-
补充一下:一般情况下,也可用特定的心跳包来刷新,而不是每次收到消息都刷新,这样一来,刷新频率就很低了,也就没必要太在乎性能开销。

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 您还有心跳吗?超时机制分析

  • Trackback 关闭
  • 评论 (35)
    • 匿名
    • 2013/12/30 9:53下午

    为什么要用TimerTask ,用java提供的线程框架不更好么

    • 我是这样考虑的,关于用什么方式来完成定时器功能,跟怎么处理超时连接也有关系,比如说仅将关闭任务提交给线程池,那Timer也未尝不可,但这并不是本文的重点,Timer最简单,所以拿TimerTask举例了

      • 纠正一下,“那Timer也未尝不可”的说法有些不当,虽然简化任务执行代码能避免Timer的一些缺陷,但没办法避免修改系统时间对Timer的影响,刚看了下1.7的Timer源码,各种调度均是基于系统时间的

    • 匿名
    • 2013/12/30 9:56下午

    个人觉得上面的volatile修饰的boolean变量并不能解决数据一致性的问题,比如,当定时器在检测if(c.reset)不成立而转到else if的时候,这时候有可能别的线程已经修改了c.reset的值。

    • 请注意后面那句:“都不会影响其逻辑含义”,你说的这种情况,可以简单的认为:refresh调用发生在了定时器此次检查之后,并且:实际超时时间本来就有波动的,超时判定也不需要多么精确

    • lucifer545
    • 2013/12/31 12:19下午

    为什么不用AtomicBoolean呢?

    • volatile boolean就能满足要求,没必要用AtomicBoolean

        • vici
        • 2013/12/31 6:01下午

        服务端用atomicboolean,效率更高

      • AtomicBoolean的内部也是用一个volatile int来实现的,请说明“服务端用atomicboolean,效率更高”的具体原因吧。

        顺便说下我刚才的测试结果,对比的是volatile读写和AtomicBoolean的compareAndSet(你指的应该是这个方法吧)
        [code lang=”java”]
        //vb为volatile boolean,ab为AtomicBoolean,b为普通boolean
        for (int n = 0; n < TEST_SIZE; n++){
        if(vb == b)
        vb = !b;
        b=!b;
        }
        //和
        for (int n = 0; n < TEST_SIZE; n++){
        ab.compareAndSet(b, !b);
        b=!b;
        }
        [/code]
        当都成功更新变量值的情况下,两者效率持平,但当没有更新变量值的情况下(也就是if不成立,和cas更新失败,在上面的测试代码中,将b=!b注释掉),后者耗时比前者多了一个数量级,使用的是跟文中类似的测试方法。

    • Ryan
    • 2014/01/02 10:04下午

    好文章!!!

    • 煎鸡蛋
    • 2014/01/07 6:01下午

    好文章,作者对代码的态度值得学习

    • 匿名
    • 2014/01/08 3:29下午

    扯了半天就一句“减少volatile写”。以后写文章别浪费大家时间还是很有必要的!!!

    • Steven
    • 2014/01/09 11:48上午

    是个办法,但算法应该采用hashwheel会更好,而且这种全局扫描不如自己内部创建定时器(自己构建,而非java库),由全局定时器调度,当然您这篇文章目的在于降低锁的访问和竞争,目的达到了。

    • 看过你的评论后,我仔细琢磨过,我认为hashwheel不适合用在此处,因为此处避免不了对所有连接扫描一遍,各连接需要频繁的重置自己的计时,hashwheel能做的,顶多替代每次的count++,只对wheel自增一次,不过同时增加了新的问题,之前直接使用connections就可以了,现在被分隔到wheel的每个格子里,增加了额外的维护一致性的成本,有点得不偿失了(关于hashwheel,临时查的资料,如果解理有偏差还请指正)。
      我再来说下全局扫描。上面的问题我打个比方,如果老师想知道哪些学生来上课了,要么对每张桌子扫一眼,看谁来了;要么让来了的人,到老师那签下到,然后老师直接查签到表。应该没有第三种形式了吧?
      第一种方式就是我文中采取的办法,坏处是要全局扫描,第二种方式确实是避免了全局扫描,但坏处是,每个学生得按顺序去签到,同学间对签到表互相竞争,前者适合大部分学生都到课的情况,后者适合,少数人到课的情况。
      正如你最后面所说:“文章目的在于降低锁的访问和竞争”,如果不违背这个前提,我认为hashwheel在此不能再发挥了。
      你说的自己内部创建定时器,我不太明白,还请细说一下

        • Kanepan
        • 2014/01/16 4:01下午

        相比定时去遍历client ,可以考虑使用 DelayQueue ..

      • 此处需要频繁重置超时时间,DelayQueue不适合此场景,可能你还没理解上面我打的那个比方及分析

    • 匿名
    • 2014/01/17 10:47上午

    您的程序是非线程安全的,在判断
    if (reset == false)
    reset = true;
    }
    会发生重入,千万别再误导读者了。

    • 重入又怎么样?多个线程进入if分支,同时把reset设置为true,会引发什么问题吗?

      文中已经说了:“但无论这两个线程怎么穿插执行,都不会影响其逻辑含义”,评论里也有人提及了,还请您本着严谨的态度,对自己的言论负责,不要误导读者。

    • 匿名
    • 2014/01/20 3:32下午

    看到这,可能不少读者已经看出问题来了,那就是内存可见性问题,调用refresh方法的线程跟执行定时器的线程肯定不是一个线程,那run方法中读到的lastTime就可能是旧值,即可能将活跃的连接判定超时,然后被干掉。

    你在后面也写了,时间的判断并不需要多么的精确,通常服务器判断线程是否超时,都是会有一定的误差,而且检查频率也不会是在超时时间内只检查一次。那么即使误判了,该请求压着超时时间过来,也属于异常情况。

    • 不是这样的,开始的方案是没有任何保证的,也就是说,就算某个连接每隔 TIMEOUT_THRESHOLD/10 收到一条数据,它也可能被干掉,因为没有可见性保证,读到的lastTime可能一直都是某个旧值。
      与此不同的是,后面的方案,是能保证在某个波动范围内,一定会干掉超时连接,或一定不会干掉活跃连接

        • 匿名
        • 2014/01/22 3:56下午

        通过lastTime获取到的值,只可能会是上次,或者上上次的旧值,不可能一直都是某个旧值,如果是这样,那就是lastTime的更新机制出了问题。
        如果在第一次调用lastTime获取到旧值,那么在第二次调用lastTime时,这两次调用的间隙(这个间隙应该会比cup的响应间隙长吧),lastTime应该已经更新了,多线程对同一个值进行读写,不可能lastTime的更新一直获取不到响应

      • 建议查阅JVM内存模型、happens-before等相关资料,本站就有不少这方面的文章

  1. 这作者自大的非常逗比哈哈,看评论很欢乐

    • 能这样回复别人,也算是性情中人了吧:)
      “我有点完美主义,自认为上面的方案在我当前掌握的知识下,已经很漂亮了”,我这样说,也是为了更容易得到别人的意见,更多的交流。
      回复中基本上是就事论事地讨论技术,除了个别人随便指责,我的回复带了些情绪外,其它地方,没什么值得攻击的吧?

  2. 好像没有注意到++c.count的问题,++ 操作不是线程安全的哦

    • 你说的没错,前++也不是线程安全的,但我代码中的那个操作并没有多个线程会执行到那里,所以不需要考虑线程安全问题

      • 另外,花了点时间 写了下我的另外一种思路,权当讨论哈,不较真,讨论为主,我也在并发编程网发了,不过估计要等几天才能发出来,原始地址在这里,可以看看:http://www.liuinsect.com/2014/03/11/%E4%B8%80%E7%A7%8D%E8%B6%85%E6%97%B6%E6%8E%A7%E5%88%B6%E7%9A%84%E6%96%B9%E5%BC%8F/

      • 我觉得只要不随便攻击他人,较真也没什么不好的,特别是讨论技术:)
        你的文章我已经看到了,稍后回复

    • vvv
    • 2014/03/14 4:23下午

    测试一千万次,volatile写+取系统时间 238932949 纳秒, 什么样的需求要求反应那么高,要求太高了!

    • 容我说明一下吧,我写本文的目的,一是给出一种解决方案;二是给出一种思路,文中比较侧重还原那个过程,期盼交流。
      refresh只是业务执行过程中,附带执行的一小部分,能减少点耗时总归是好的,特别是在高并发、海量连接的情景下,并且代码也比较简洁,没有以增加复杂度为代价,假若业务处理过程比较耗时的话,那在refresh里压榨性能也只是一厢情愿了。

    • Inapt
    • 2014/03/16 7:04下午

    感觉博主写的挺好的。。。为什么被喷的这么厉害。。。=。=。。。

    用精确性换效率的思路非常赞!

    • 多谢赞同,特别是后面那句:)

      • 思路的确很赞,前一种思路比较耗时我想主要是因为System.currentTimeMills是system call,一般精度要求不高的场景,都会自己实现SystemTimer缓存时间,性能亲测提升巨大

    • ryan
    • 2014/05/15 3:58下午

    有趣的是这里System.out.println(-time + (time = System.nanoTime()));
    我自己写想不到这种写法

    • 匿名
    • 2014/07/30 12:12下午

    其实既然谈到 精确度不需要很高,我们可以没执行一次timerTask 取一次系统时间,然后用这个实践遍历整个connecions,因为时间比较很快,所以精确度不会相差太多,我想一般的timout 的单位 都应该是 秒级别的。另外如果close connection 需要花比较多的时间,可以考虑把算出timeout 的connection。放到另外一个queue 中,有另外一个线程去处理。

return top