从超时处理说起,我们可以谈些什么

起源

有朋友说过一个场景:调用一个方法,但是该方法可能会一直阻塞下去,所以要设计超时机制,该如何设计?


首先想到的是 Future 接口,其可以在超时的时候抛出超时异常,从而执行超时后的一些处理逻辑。Future 的实现类为 FutrueTask ,其继承关系如下:

可见,每个 FutureTask 对象都实现了 Runnable 接口,也就是每个 FutureTask 都可以作为创建一个新线程的 runnable 参数。

这样子看,Futrue 的异步执行的本质是在当前线程新开一个线程去执行 task 任务,该 task 任务分为两块内容:

  • 返回任务处理结果
  • 超时检测

比较好奇超时检测是如何实现的,所以决定再看看。

在这里想到了 程序中使用Http请求的时候,应该也会涉及到超时处理,那么它又是怎么处理的呢?这个后续研究。

LockSupport

追踪源码可以看到,在 FutureTask 的带有超时时间参数的 get 方法中,最终在 awaitDone 方法中可以看到:

else if (timed) {
                nanos = deadline - System.nanoTime();
                if (nanos <= 0L) {
                    removeWaiter(q);
                    return state;
                }
                LockSupport.parkNanos(this, nanos); // 这里是关键的超时处理
            }

LockSupport 提供了线程的阻塞等机制,在其 parkNanos 方法实现中,可以看到下面的代码:

    public static void parkNanos(Object blocker, long nanos) {
        if (nanos > 0) {
            Thread t = Thread.currentThread();
            setBlocker(t, blocker);
            UNSAFE.park(false, nanos);  // 这里是关键
            setBlocker(t, null);
        }
    }

该方法中调用了 Unsafe 类中的 park() 方法。Unsafe 类主要负责执行一些不安全的操作,比如自主管理内存的资源等。该 park 方法的声明如下:

public native void park(boolean b, long l);

可见,这是一个 native 方法,也即本地方法。

方法执行的时候的需要 栈内存 进行操作,那么对于 native 方法来说,其运行时栈就是 jvm 内存结构中的 本地方法栈, 那么 Unsafe 中对于内存的管理,则是针对 堆外内存 的管理。

native 方法:我们可以根据 JNI(java本地接口) 规范来编写 native 方法的实现,通常使用 C/C++ 编写,比较偏向底层。


那么,这里的 park() 方法的具体实现到底是什么呢?

park方法实现

在 Linux 平台上,是使用基于 POSIX 规范的API来实现的park方法。其实现的关键代码如下:

int os::PlatformEvent::park(jlong millis) {
    // 忽略一堆
    while (_event < 0) {
      status = pthread_cond_timedwait(_cond, _mutex, &abst);  // 关键在这里
      assert_status(status == 0 || status == ETIMEDOUT,
                    status, "cond_timedwait");
      // OS-level "spurious wakeups" are ignored unless the archaic
      // FilterSpuriousWakeups is set false. That flag should be obsoleted.
      if (!FilterSpuriousWakeups) break;
      if (status == ETIMEDOUT) break;
    }
}

可以看到,关键的超时等待是 pthread_cond_timedwait() 方法,该方法属于 glibc 库中的方法。

我们知道,linux 对外支持了许多系统调用,而这些系统调用其实不会被系统开发人员直接使用,大家使用的都是 glibc 中的方法,glibc 相当于对系统调用做了一层封装。

该方法中最终调用了 futex 这个真正的系统调用,该系统调用中,使用了定时器 hrtimer 这个高精度定时器来实现超时处理。默认地,该定时器 x us计时一次(没验证,参考是50)。定时时间到了可以执行对象的中断处理函数进行后续处理。

至于什么时候执行 unpark,以及后续动作如何联动处理,都是通过 信号量 机制来实现各种同步互斥的,这里暂不关注。

到了这里,需要对 sleep 这个java 方法进行对比研究,看看它底层是如何实现的。

java sleep 实现原理

同样的,Thread.sleep() ,该方法也是 native 方法,其具体实现为:

int os::sleep(Thread* thread, jlong millis, bool interruptible) {
    // 一堆代码
    slp->park(millis); // 关键
}

可以看到,其本身也是调用 park 方法实现的。

那么,疑问来了,LockSupport.parkNanosThread.sleep 的区别是什么?存在即合理,所以肯定是有区别的。

再说 FutureTask

上文说的重点在于 定时的处理,现在需要将重点转移到同步的问题上来。

LockSupportparkunpark 方法,都需要传入需要阻塞或者解除阻塞的线程。当A线程调用 get 方法时,其内部的实现上是将 this 作为参数传入的,也即将当前的A线程阻塞起来。那么什么时候解锁呢?有两个触发方式。

超时解锁

再仔细看一下上文的 awaitDone 方法:下面已经删除了一些条件判断

private int awaitDone(boolean timed, long nanos)
    throws InterruptedException {
    final long deadline = timed ? System.nanoTime() + nanos : 0L;
    WaitNode q = null;
    boolean queued = false;
    for (;;) {
        if (Thread.interrupted()) {
            removeWaiter(q);
            throw new InterruptedException();
        }

        int s = state;
        if (s > COMPLETING) {
            if (q != null)
                q.thread = null;
            return s;
        }
        else if (timed) { // 超时判断
            nanos = deadline - System.nanoTime();
            if (nanos <= 0L) {
                removeWaiter(q);
                return state;
            }
            LockSupport.parkNanos(this, nanos);
        }
        else 
            LockSupport.park(this);
    }
}

假设在调用 parkNanos 期间任务没有完成,则会阻塞当前线程,直到定时器计时结束后,再唤醒该线程。从而该线程继续在 for 循环中判断 state 状态,随即就会发现处于超时状态,所以上层方法会抛出异常,如下:

public V get(long timeout, TimeUnit unit)
    throws InterruptedException, ExecutionException, TimeoutException {
    if (unit == null)
        throw new NullPointerException();
    int s = state;
    if (s <= COMPLETING &&
        (s = awaitDone(true, unit.toNanos(timeout))) <= COMPLETING)  // 超时则返回值不会时完成状态
        throw new TimeoutException(); // 所以抛出超时异常
    return report(s);
}

还有另一种触发条件就是任务完成的触发。

任务完成触发

我们肯定要先把 FutureTask 任务跑起来才好执行 get 等方法,所以首先要调用 run 方法,run方法的实现中,最终会在 方法完成后执行如下的代码:

for (;;) {
                Thread t = q.thread;
                if (t != null) {
                    q.thread = null;
                    LockSupport.unpark(t);
                }
                WaitNode next = q.next;
                if (next == null)
                    break;
                q.next = null;
                q = next;
            }

该方法中会对线程执行 unpark 操作唤醒线程,所以可以实现任务完成后 get 方法就会立刻返回。所以 park 和 unpark 的组合使用,可以实现 sleep 实现不了的功能。

小小总结

通过上述研究,可以总结。这种情境下的超时处理不论如何都需要阻塞一个线程。

HttpClient 中的超时

那么,HttpClient 中的超时又是如何处理的呢?其涉及到三种超时设置:

  • ConnectionRequestTimeout:如果配置了连接池,那么如果池中没有可用连接,则最多等 ConnectionRequestTimeout 时间
  • ConnectTimeout : 拿到连接以后,和通信的另一方建立连接的超时时间。即TCP通道的建立超时。
  • SocketTimeout:连接建立以后,数据读取的超时时间。

第一种池子中获取连接的超时和上文研究的超时比较像,而后两种主要涉及到的是 系统底层 和 网络层的超时处理,情况不太一样,暂不继续研究。

linux 定时器的实现

最后想说的是 Linux 中定时器的实现,还是一些数据结构优化问题,感兴趣可以继续看下延伸阅读中的文章,其给出了一般意义上的定时器实现机制,对于嵌入式人员来说也有很大的参考价值。

实际上,定时器系统也是 linux 内核中一个很大的范畴。

总结

实际上本篇引申出了很多可以继续研究的东西,比如:

  • wait 和 notify 的实现原理对比
  • linux内核定时器系统
  • JNI实现规范
  • 网络超时的底层原理
  • POSIX规范
  • 无锁化编程
  • 信号量同步互斥机制
  • 等等

延伸阅读

发布了39 篇原创文章 · 获赞 74 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/zhou307/article/details/102340813