localtime函式的死鎖風險

小米運維發表於2019-02-12

本文分析了localtime函式的實現,及其可能帶來的死鎖風險。
上篇文章回顧:一個SRE的日常

————————————————————————————————————

1、localtime函式說明

(1) 函式定義

struct tm *localtime(const time_t *t);複製程式碼

(2) 函式說明:將1970.01.01 00:00:00 到現在經過的秒數轉換為換成真實世界所使用的時間日期。

(3) 返回值:返回結構tm的指標,代表目前的當地時間。

2、localtime函式使用

int main(int argc, char *argv[]) {    
    time_t t0 = time(NULL);    
    time_t t1 = t0 + 1800;   
    struct tm* tm1 = localtime(&t0);    
    struct tm* tm2 = localtime(&t1);     
    char str1[50] = {0};    
    char str2[50] = {0};   
     
    strftime(str1, 50, "%H:%M:%S", tm1);    
    strftime(str2, 50, "%H:%M:%S", tm2);  

    printf("%s
", str1);    
    printf("%s
", str2);  

    return 0; 
    }複製程式碼

假設當前執行時間為22:00:26,那麼上述程式碼執行結果會是什麼呢?

[xxxx@xx-xxx-xxxx-xx00 test]$ gcc localtime_test.c -o test 
[xxxx@xx-xxx-xxxx-xx00 test]$ ./test
22:30:26 
22:30:26複製程式碼

輸出的兩個時間其實是一樣的,兩者並不是相差半個小時。猜想第二次呼叫localtime的返回值把tm1的值給覆蓋了,內部可能使用了tm型別全域性變數。檢視localtime函式程式碼如下:

/* The C Standard says that localtime and gmtime return the same pointer. */
struct tm _tmbuf;

.....

/* Return the `struct tm` representation of *T in local time. */

struct tm * localtime (const time_t *t) {  
return __tz_convert (t, 1, &_tmbuf); 
}複製程式碼

可以看到,localtime呼叫__tz_convert傳入的第三個引數_tmbuf是一個全域性變數,並且__tz_convert返回值就是_tmbuf的指標。返回指標所指向的全域性變數可能被其他執行緒呼叫localtime給覆蓋掉。因此,localtime並不是執行緒安全的。應使用執行緒安全的localtime_r函式代替它。localtime_r程式碼如下:

static struct tm * 
localtime_r (const time_t *t, struct tm *tp) 
{  
  struct tm *l = localtime (t);  
  if (! l)    
   return 0;   
  *tp = *l;  
  return tp; 
  }複製程式碼

由此可見,localtime_r內部還是呼叫了localtime,但是,每次呼叫完成後,馬上將返回結果填充到傳入的第二個引數tp指向的記憶體裡,再返回tp,因此,該函式執行緒是可重入的.

儘管在多執行緒下,呼叫localtime_r是執行緒安全的,但是效能可能會受到影響。例如:在輸出日誌輸出時,我們需要通過localtime_r獲取當前時間,多執行緒併發呼叫時,容易發生鎖等待,導致效能下降。鎖的產生來源於__tz_convert的呼叫,該函式的部分實現如下:

struct tm * 
__tz_convert (const time_t *timer, int use_localtime, struct tm *tp) 
{  
  long int leap_correction;  
  int leap_extra_secs;    
   ......   
    
  __libc_lock_lock (tzset_lock);   //加鎖   
   ...... //時間轉換邏輯   
  __libc_lock_unlock (tzset_lock); //釋放鎖     
   ...... 
  return tp; 
 }複製程式碼

3、死鎖問題

localtime_r是執行緒安全的,但是,對如下兩種情況並不安全,甚至會引發死鎖。

(1)訊號處理函式呼叫localtime:假如程式呼叫localtime,已經獲取全域性鎖,且並沒有釋放。此時,如果程式接收到訊號,在訊號處理函式也呼叫了localtime,就會造成死鎖。

(2)多執行緒下fork:在多執行緒下,若執行緒A呼叫localtime,已經獲取全域性鎖,尚未釋放鎖。此時,假如執行緒B呼叫了fork,並且在子程式也呼叫localtime,也會造成死鎖,導致子程式一直被hang住。因為fork出來的子程式只會複製呼叫它的執行緒,而其他執行緒不會被複制到子程式執行,也就是說當前子程式中只有執行緒B在執行。子程式會複製父程式的使用者空間資料,包括了鎖的資訊。

Redis的日誌輸出函式redisLogRaw中也是呼叫localtime來獲取時間的。同時,Redis也是會存在多執行緒fork的情況。那麼,Redis會不會有前文提到的效能問題和死鎖問題呢?

在5.0.0之前的版本,Redis確實是使用localtime來進行時間轉換。但是,由於Redis是單執行緒的架構,不存在競爭的情況,因此,一般情況下,不存在多個執行緒呼叫競爭而導致效能下降或者執行緒不安全的問題。

我們說Redis是單線的,指它在處理所有的請求都是在一個執行緒完成的。其實,Redis還是有後臺執行緒,非同步去完成某些任務的。截至目前版本,Redis共有三個後臺執行緒,作用如下:

  • 非同步關閉檔案

  • AOF的刷盤

  • 非同步刪除大Key

在一定條件下,Redis會啟用這些執行緒後臺完成一些任務。因此,還是存在前文提到死鎖的風險。既然阻塞的方式獲取時間轉換會導致死鎖,那麼,就需要一個無鎖、無阻塞的函式替換掉localtime。在5.0.0版本中就實現了這樣一個函式——nolocks_localtime,如下程式碼實現:

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. */

}複製程式碼

nolocks_localtime在功能上與localtime一樣,都是將time_t型別的時間戳轉換成包含年月日的tm型別,但是,它是非阻塞的、無鎖的,且執行緒安全的,多執行緒下fork也是安全的。缺點就是需要自己實現時間的轉換邏輯。

總結

(1)在實現日誌輸出函式或者介面時,時間轉換應當儘量避免使用localtime或者localtime_r函式,尤其是在多執行緒的呼叫環境下,可能會影響程式的效能。可以考慮使用一個全域性的時間變數,讓某個執行緒定期去更新該時間變數,其他執行緒直接讀取該時間變數。Redis也有類似的用法,在serverCron函式(預設每秒呼叫10次)中呼叫updateCacheTime函式來更新全域性的unix time。

(2)訊號處理函式或多執行緒fork()中,儘量避免使用這種會持有全域性鎖的函式,以免造成死鎖的情況。

本文首發於公眾號”小米運維“,點選檢視原文

相關文章