sem_timedwait() 阻塞导致网口单通故障分析

这几天遇到一个网口单通故障,最后定位发现是 sem_timedwait() 阻塞导致,现记录下定位过程。

故障现象

在测试过程中发现,使用 SGMII 对接的两个设备,设备 D1 上的端口显示为 link down,而设备 D2 上的端口显示为 link up,出现了单通的现象。

故障分析

1、为什么会出现单通现象?

对于使用背板实现的 SGMII 接口,出现单通现象的概率极低,首先需要排查的是两边设备是否准确地获取到了端口的 link 状态。通过比较两边设备源码发现,设备 D2 获取端口状态时是直接读取交换芯片的寄存器,而设备 D1 获取端口状态时是读取保存在交换芯片 SDK 中的软件变量。各个端口的 link 状态是通过一个线程定时从硬件读取并保存在上述软件变量中的。

于是在设备 D1 上手动调用交换芯片 SDK 中直接读取端口底层 SerDes 状态的 API,结果确实是 link up 的。也就是说,在底层 SerDes 层面,两边的端口都是 link up 的。由此可见,是设备 D1 的软件层面保存的状态与硬件 SerDes 状态不同步,导致出现了单通的「假象」。

2、为什么硬件状态没有同步到软件变量中?

上述的端口 link 状态同步线程,会每隔 250 ms 将底层 SerDes 状态同步到软件变量中,应用系统在调用相应的 API 获取端口状态时,就是直接返回软件变量中保存的对应端口的 link 状态等信息。从第 1 步的分析可知,在出现故障时,这个线程并没有成功把底层 SerDes 状态同步到软件变量中。

查看 SDK 源码可知,link 状态同步线程在循环中通过调用 sal_sem_take() 获取一个信号量,并设置了 250ms 的超时时间,可能有两种结果:

  • 假如在这 250ms 之内,其它线程调用了修改端口速率、接口模式等 API 导致端口状态改变而进一步调用 sal_sem_give() 释放了信号量,则 link 状态同步线程立即获取到信号量并继续执行将状态有变化的端口的最新状态同步到软件变量中,以此识别本端的状态变化。
  • 假如在这 250ms 之内都没有线程调用 sal_sem_give() 做释放信号量操作,则 250ms 之后,sal_sem_take() 将以超时的形式返回,并主动获取底层 SerDes 状态并与保存的状态比较,有变化则相应地更新保存的状态,以此识别诸如对端设备断开导致的状态变化。

通过在故障设备上设置断点逐步缩小故障范围发现,link 状态同步线程正是阻塞在 sal_sem_take() 上而没有继续执行循环,导致没有把硬件状态同步到软件变量中的。

3、为什么 sal_sem_take() 操作会阻塞?

再深入到 sal_sem_take() 的内部,获取信号量并做超时等待的部分代码如下:

if (_sal_compute_timeout(&ts, usec)) {
    while (1) {
        if (!sem_timedwait(&s->s, &ts)) {
            err = 0;
            break;
        }
        if (errno != EAGAIN && errno != EINTR) {
            err = errno;
            break;
        }
    }
}

其中,sem_timedwait() 是 POSIX 标准接口,通过传入未来的某个时钟实现超时等待信号量的获取,具体请参考 sem_timedwait(3)。而 _sal_compute_timeout() 的实现如下:

static int _sal_compute_timeout(struct timespec *ts, int usec)
{
    int sec;
    uint32 nsecs;

    if (clock_gettime(CLOCK_REALTIME, ts) == 0) {
        ;
    }
    else
    {
        struct timeval  ltv;

        /* Fall back to RTC if realtime clock unavailable */
        gettimeofday(&ltv, 0);
        ts->tv_sec = ltv.tv_sec;
        ts->tv_nsec = ltv.tv_usec * 1000;
    }
    /* Add in the delay */
    ts->tv_sec += usec / SECOND_USEC;

    /* compute new nsecs */
    nsecs = ts->tv_nsec + (usec % SECOND_USEC) * 1000;

    /* detect and handle rollover */
    if (nsecs < ts->tv_nsec) {
        ts->tv_sec += 1;
        nsecs -= SECOND_NSEC;
    }
    ts->tv_nsec = nsecs;

    /* Normalize if needed */
    sec = ts->tv_nsec / SECOND_NSEC;
    if (sec) {
        ts->tv_sec += sec;
        ts->tv_nsec = ts->tv_nsec % SECOND_NSEC;
    }

    /* indicate that we successfully got the time */
    return 1;
}

可以看到,_sal_compute_timeout() 先调用了 clock_gettime(CLOCK_REALTIME, ts) 获取当前的实时时钟,再据此计算超时的时钟(本例中为 250ms 之后的时钟)。

分析 sal_sem_take() 的实现可知,有三种可能导致其阻塞的原因:

  • 调用 sem_timedwait() 时返回 EAGAINEINTR 导致 while 进入死循环。
  • 调用 _sal_compute_timeout() 计算的超时时钟不准,导致 sem_timedwait() 要等待的超时时钟向后大幅度偏移而阻塞。
  • 系统实时时钟被修改,导致 sem_timedwait() 用于计算是否达到超时时钟的基准时钟向前大幅度偏移而阻塞。

通过在 sal_sem_take() 中添加打印排除了前两种可能性,并结合「link 状态同步线程每次阻塞都是发生在上层子系统开始初始化时」这一现象,将怀疑点转换到上层子系统的代码中。

果然,通过搜索 clock_settime 发现,上层子系统为了对系统时钟做统一管理,在初始化时会运行如下代码初始化系统实时时钟:

struct timespec tTime;
tTime.tv_sec  = 0;
tTime.tv_nsec = 0;
clock_settime(CLOCK_REALTIME, &tTime);

也就是说,将系统实时时钟设置成全 0,即 1970 年 1 月 1 日 00:00:00.000。由于正常情况下 link 状态同步线程每 250ms 就会调用一次 _sal_compute_timeout()sem_timedwait(),假如上层子系统设置实时时钟的代码在调用这两个接口之间执行了,就会使得 sem_timedwait() 长时间阻塞,也就是上述第三种导致 sal_sem_take() 阻塞的原因。

举个具体的例子。假如 link 状态同步线程调用 _sal_compute_timeout()clock_gettime(CLOCK_REALTIME, ts) 的时间是 2017 年 1 月 7 日 15:44:45.000(时钟 1),则计算出来的 250ms 之后的时间是 2017 年 1 月 7 日 15:44:45.250(时钟 2),此后上层子系统调用了 clock_settime(CLOCK_REALTIME, &tTime) 将系统实时时钟设置成了 1970 年 1 月 1 日 00:00:00.000。当 link 状态同步线程继续执行到 sem_timedwait() 时,基于当前的系统实时时钟判断是否到达超时时间(时钟 2),所以需要等待 47 年零 6 天多!

这就是故障的根本原因。

解决方法

虽然「罪魁祸首」是上层子系统的代码,但他们的一整套管理机制肯定不会轻易修改,那只好修改我们自己的代码,具体来说就是修改 link 状态同步线程的超时等待实现机制。幸运的是,交换芯片的 SDK 对此已有支持,只需定义相关的宏即可打开另外两种实现分支。这两种实现分别简述如下:

1. 不使用 sem_timedwait(),而改用 sem_trywait()。

sem_trywait() 是和 sem_timedwait() 同一系列的 POSIX 标准接口。在实现时,可先调用 sem_trywait() 尝试获取信号量,假如获取不到,再使用 usleep() 等待 1us 后再次尝试获取,如此循环,直到超时的时间(本例中为 250ms)到达为止。参考代码如下:

int time_wait = 1;
for (;;) {
    if (sem_trywait(&s->s) == 0) {
        err = 0;
        break;
    }

    if (errno != EAGAIN && errno != EINTR) {
        err = errno;
        break;
    }

    if (time_wait > usec) {
        time_wait = usec;
    }

    sal_usleep(time_wait);

    usec -= time_wait;

    if (usec == 0) {
        err = ETIMEDOUT;
        break;
    }

    if ((time_wait *= 2) > 100000) {
        time_wait = 100000;
    }
}

2. 不使用系统实时时钟,而改用单调递增时钟。

除了实时时钟之外,还有一个单调递增时钟,此时钟从某一时刻开始单调递增而不会被修改,详见 clock_gettime(3)。具体实现时,使用 clock_gettime(CLOCK_MONOTONIC, &ts) 获取单调递增时钟,再基于此时钟计算超时时间。此时同样不能使用 sem_timedwait(),因为这个接口就是使用系统实时时钟计算超时的,需要改用 pthread_cond_timedwait() 实现。参考代码如下:

if (err == 0) {
    err = pthread_mutex_lock(mutex);

    while ((*val == 0) && (err == 0)) {
        if (forever) {
            err = pthread_cond_wait(cond, mutex);
        } else {
            err = pthread_cond_timedwait(cond, mutex, &ts);
        }

    }

    if (err == 0) {
        *val -= 1;
    }

    /* even if there's an error, try to unlock this... */
    pthread_mutex_unlock(mutex);
}

总结

在会修改系统实时时钟的应用中,需要谨慎使用 sem_timedwait()。

以上。

comments powered by Disqus