localtime函数死锁分析

一、简介

前段时间,线上的redis实例出现了一些异常的情况,具体变现就是bgsave子进程hang住了,从而引发了后续的很多问题,通过排查最终发现是localtime相关函数引起的,这里做一下总结记录。

C 库函数 *struct tm *localtime(const time_t timer) 作用是根据本地时区信息将 time 函数获取的 UTC 时间调整为为本地时间,并将具体的时间信息填充到tm结构体之中;

二、详细介绍

由于localtime函数的具体底层实现的缘由,在某些场景下会触发localtime函数导致的死锁问题,这里详细的分析原因以及后续的处理方案;

2.1 底层实现分析

localtime函数底层的调用栈信息为:

localtime()   =>   __tz_convert()
  • localtime 函数的底层代码实现(代码位于./time/localtime.c):
/* Return the `struct tm' representation of *T in local time.  */
struct tm *
localtime (const time_t *t)
{
return __tz_convert (t, 1, &_tmbuf);
}
  • __tz_convert 函数的底层代码实现(代码位于./time/tzset.c):
/* Return the `struct tm' representation of *TIMER in the local timezone.
Use local time if USE_LOCALTIME is nonzero, UTC otherwise. */
struct tm *
__tz_convert (const time_t *timer, int use_localtime, struct tm *tp)
{
long int leap_correction;
int leap_extra_secs;

if (timer == NULL)
{
__set_errno (EINVAL);
return NULL;
}

__libc_lock_lock (tzset_lock);

/* Update internal database according to current TZ setting.
POSIX.1 8.3.7.2 says that localtime_r is not required to set tzname.
This is a good idea since this allows at least a bit more parallelism. */
tzset_internal (tp == &_tmbuf && use_localtime);

if (__use_tzfile)
__tzfile_compute (*timer, use_localtime, &leap_correction,
&leap_extra_secs, tp);
else
{
if (! __offtime (timer, 0, tp))
tp = NULL;
else
__tz_compute (*timer, tp, use_localtime);
leap_correction = 0L;
leap_extra_secs = 0;
}

__libc_lock_unlock (tzset_lock);

if (tp)
{
if (! use_localtime)
{
tp->tm_isdst = 0;
tp->tm_zone = "GMT";
tp->tm_gmtoff = 0L;
}

if (__offtime (timer, tp->tm_gmtoff - leap_correction, tp))
tp->tm_sec += leap_extra_secs;
else
tp = NULL;
}

return tp;
}

解释分析:

__tz_convert 使用的是一个static变量的tzset_lock全局锁;

  • 线程安全?:由于使用直接返回的是一个全局变量,这里并不是线程安全的;
  • 信号安全?:localtimelocaltime_r这两个函数都不是信号安全的,如果在信号处理函数中使用,就要考虑到死锁的情况,比如,程序调用localtime_r,加锁后信号发生,信号处理函数中也调用localtime_r的话,会因为获取不到锁所以一直阻塞;

对于线上资源出现异常的分析

对于redis类资源,本身存在一个主线程以及一些bio线程,对于bio线程来说,输出日志是很常见的事情,但是存在这么一种场景,当父进程中其中一个bio线程正在输出日志,此时fork了一个子进程开始执行bgsave,由于子进程会继承父进程的锁,所以对于子进程来说,当它尝试输出有一些日志的信息,就会由于已经拥有一个锁而导致出现死锁的情况,进而导致子进程会出现hang住的情况。

2.2 解决方案

使用全局变量的方式timezone以及daylight的相关信息,避免调用localtime函数;

方案参考(redis的官方解决方案):

#include <time.h>

static int is_leap_year(time_t year) {
if (year % 4) return 0; /* A year not divisible by 4 is not leap. */
else if (year % 100) return 1; /* If div by 4 and not 100 is surely leap. */
else if (year % 400) return 0; /* If div by 100 *and* 400 is not leap. */
else return 1; /* If div by 100 and not by 400 is leap. */
}

void nolocks_localtime(struct tm *tmp, time_t t, time_t tz, int dst) {
const time_t secs_min = 60;
const time_t secs_hour = 3600;
const time_t secs_day = 3600*24;

t -= tz; /* Adjust for timezone. */
t += 3600*dst; /* Adjust for daylight time. */
time_t days = t / secs_day; /* Days passed since epoch. */
time_t seconds = t % secs_day; /* Remaining seconds. */

tmp->tm_isdst = dst;
tmp->tm_hour = seconds / secs_hour;
tmp->tm_min = (seconds % secs_hour) / secs_min;
tmp->tm_sec = (seconds % secs_hour) % secs_min;

/* 1/1/1970 was a Thursday, that is, day 4 from the POV of the tm structure
* where sunday = 0, so to calculate the day of the week we have to add 4
* and take the modulo by 7. */
tmp->tm_wday = (days+4)%7;

/* Calculate the current year. */
tmp->tm_year = 1970;
while(1) {
/* Leap years have one day more. */
time_t days_this_year = 365 + is_leap_year(tmp->tm_year);
if (days_this_year > days) break;
days -= days_this_year;
tmp->tm_year++;
}
tmp->tm_yday = days; /* Number of day of the current year. */
/* We need to calculate in which month and day of the month we are. To do
* so we need to skip days according to how many days there are in each
* month, and adjust for the leap year that has one more day in February. */
int mdays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
mdays[1] += is_leap_year(tmp->tm_year);

tmp->tm_mon = 0;
while(days >= mdays[tmp->tm_mon]) {
days -= mdays[tmp->tm_mon];
tmp->tm_mon++;
}

tmp->tm_mday = days+1; /* Add 1 since our 'days' is zero-based. */
tmp->tm_year -= 1900; /* Surprisingly tm_year is year-1900. */
}

#ifdef LOCALTIME_TEST_MAIN
#include <stdio.h>

int main(void) {
/* Obtain timezone and daylight info. */
tzset(); /* Now 'timezome' global is populated. */
time_t t = time(NULL);
struct tm *aux = localtime(&t);
int daylight_active = aux->tm_isdst;

struct tm tm;
char buf[1024];

nolocks_localtime(&tm,t,timezone,daylight_active);
strftime(buf,sizeof(buf),"%d %b %H:%M:%S",&tm);
printf("[timezone: %d, dl: %d] %s\n", (int)timezone, (int)daylight_active, buf);
}
#endif

三、相关函数解析

3.1 time()

time函数会返回从公元 1970 年1 月1 日的UTC 时间从0 时0 分0 秒算起到现在所经过的秒数。如果 t 并非空指针的话,此函数也会将返回值存到t 指针所指的内存;

函数声明:time_t time(time_t *t)

返回值:成功则返回秒数,失败则返回((time_t)-1)值,错误原因存于errno 中;

3.2 gettimeofday()

gettimeofday() 函数会将目前的时间存储在 tv 所指的结构中,将当地时区的信息则放到 tz 所指的结构中并返回;

函数声明:int gettimeofday (struct timeval * tv, struct timezone * tz)

结构体定义:

struct timeval {
long tv_sec; //秒
long tv_usec; //微秒
};

struct timezone {
int tz_minuteswest; //和Greenwich 时间差了多少分钟
int tz_dsttime; //日光节约时间的状态
};

tz_dsttime所代表的状态信息为:

  • DST_NONE //不使用
  • DST_USA //美国
  • DST_AUST //澳洲
  • DST_WET //西欧
  • DST_MET //中欧
  • DST_EET //东欧
  • DST_CAN //加拿大
  • DST_GB //大不列颠
  • DST_RUM //罗马尼亚
  • DST_TUR //土耳其
  • DST_AUSTALT //澳洲(1986 年以后)

返回值:成功则返回0,失败返回-1,错误代码存于errno;

3.3 setlocale()

C 库函数 - *char *setlocale(int category, const char locale) 设置或读取地域化信息;

函数声明:char *setlocale(int category, const char *locale)

  • category – 这是一个已命名的常量,指定了受区域设置影响的函数类别;
    • LC_ALL 包括下面的所有选项;
    • LC_COLLATE 字符串比较。参见 strcoll();
    • LC_CTYPE 字符分类和转换。例如 strtoupper();
    • LC_MONETARY 货币格式,针对 localeconv();
    • LC_NUMERIC 小数点分隔符,针对 localeconv();
    • LC_TIME 日期和时间格式,针对 strftime();
    • LC_MESSAGES 系统响应;
  • locale – 如果 locale 是 NULL 或空字符串 “”,则区域名称将根据环境变量值来设置,其名称与上述的类别名称相同;

返回值:如果成功调用 setlocale(),则返回一个对应于区域设置的不透明的字符串。如果请求无效,则返回值是 NULL

3.4 tzset()

C 库函数 - *char *setlocale(int category, const char locale) 设置或读取地域化信息,tzset函数在实现的时候是通过内部的tzset_internal函数来完成的;

调用方式:

  • 显式调用:直接执行tzset函数。显式调用内部的tzset_internal函数,强制tzset不管何种情况一律重新加载TZ信息或者/etc/localtime信息;
  • 隐式调用:执行localtime的时候会隐式调用tzset函数。隐式调用内部的tzset_internal函数,只有在TZ发生变化,或者加载文件名发生变化的时候才会再次加载时区信息【如果只是/etc/localtime的内容发生了变化,而文件名/etc/localtime没有变化,则不会再次加载时区信息,导致localtime函数调用仍然以老时区转换UTC时间到本地时间】;
作者: bugwz
链接: https://bugwz.com/2019/01/12/localtime/
声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 咕咕