1. 程式人生 > >linux環境下的時間程式設計

linux環境下的時間程式設計

Linux下提供了豐富的api以供開發者們處理和時間相關的問題。然而這些介面看似各自為政實則有有著千絲萬縷的聯絡,在學習和時間中引發了各種各樣的混亂。因此時間處理成為了許多Linux開發者的夢魘,遇到時間處理往往避之不及。不過只要你稍微花費一點點精力,學會在Linux上優雅的處理時間和日期也並不是什麼難事。

所以本文將會詳細介紹Linux api和c標準庫對時間的處理,對於更現代化的c++的chrono,會在另一篇文章裡再講。

本文並不會涉及定時器(timer),timer和時間有著關聯,而且timer對於程式設計師來說是極為重要的,但介紹timer介面將會花費相當可觀的篇幅,那樣多少會使本文離題,所以請允許我在另外的文章中單獨討論timer,這裡我們主要集中精力在time pointer和date time上。

本文索引

  • time的分類
  • 時間的表示
    • time_t
    • 帶有完整日曆資訊的struct tm
    • 過時的timeval
    • 更現代的timespec
  • 總結

time的分類

在討論具體的時間問題前,我們先要明確時間的概念。也許你覺得時間的概念是那麼淺顯易懂沒有什麼額外強調的必要,但對於程式來說卻不然。在程式看來時間的定義是靈活多變的,不同的定義下時間的計算是不同的,因此有必要仔細區分。

一般而言Linux上提供了三種時間,每種時間都包含了自己的含義,起點和特徵:

  • real time
    日曆時間,又叫wall-clock或者system clock,如同字面意思,是指和真實世界中同樣的時間。因此這是最直觀最容易理解的時間。

    對於Linux世界來說這個時間的起點是1970年1月1日0時(UTC),又被叫做Epoch,Linux上以此為起點的均為UTC時間。

    real time的最大特點是會受到修改系統時間的命令/api或者ntp服務的影響,因而導致時間出現跳躍。

  • monotonic time
    單調時間,意思是不能被設定和影響的時間,因此相比系統時鐘它可以提供更精確是時間資訊,也不會出現時間跳躍。

    單調時間的起點POSIX標準並沒有明確指定,但在Linux上是以系統啟動的時間為起點的。

    雖然說單調時鐘的時間是穩定的,但它會被adjtime函式和ntp服務影響,同時當系統掛起或休眠時計時會被暫停。

  • cpu time
    程式佔用的cpu執行時間。

    起點是程式開始執行的時間。

    起點說的不是很嚴謹,因為嚴格來說cpu time計算的是程式佔用的cpu的ticks數,所以程式上的使用者等待時間是不包含在內的。

總結一下,前兩種是我們接觸最多的,系統時間最常見於date time的處理,單調時間則是計時功能和定時器的基石;而cpu time雖然用的少但是在衡量程式效能時是一個重要的參考指標。

時間的表示

儲存時間的方法多如牛毛,而對於計算機來說最簡單也最有效率的方式便是記錄從起點到現在所經過的時間長度。這也是Linux上不同時間表示法的共通之處。

Linux上最常見的時間儲存方案有四種:time_tstruct tmstruct timevalstruct timespec。我們分別介紹它們。

time_t

time_t是c和c++標準庫的一部分,有標準庫背書,因此用的也是最廣泛的。

time_t主要表示日曆時間,也就是1970/1/1 0:00 UTC開始到現在的秒數。因此一部分的資料會告訴你他是長整數型別比如long的別名,為了方便你可能會將它們轉換為整數型別,這時要小心,雖然大多數情況下time_t確實和整數型別有關係,但不同的實現可能使用了不同的整數型別,比如unsigned longlong long,有時候time_t甚至可能是編譯器內建型別的別名,所以為了可移植性不要輕易斷定它的原始型別是什麼。

獲得系統時間的方法有如下幾種:

// 引數為空指標直接返回當前UTC時間
std::time_t now = std::time(nullptr);

// 引數不為空的時候也會把結果存入引數
std::time_t now_now{};
now = std::time(&now_now);

// 通過tm結構體還原成time_t
std::tm date = {.tm_year = 70}; // 1970/1/1
std::time_t t = std::mktime(&date);
std::cout << std::ctime(&t) << std::endl; // output: Thu Jan  1 00:00:00 1970

一切看起來都很自然,時間的獲取就應該是一件簡單的事情————真的是這樣嗎?給出一點提示,最後ctime的輸出真的正確嗎?

答案很遺憾是否定的。首先我們的系統處於UTC+8時區,我們設定tm為1970年1月1日,因此mktime應該返回0,但當我們用ctime輸出本地時間時卻發現時間仍然在1970/1/1 0:00:00,而沒有如我們預期的那樣+8小時,這是為什麼呢?

我們的time_t所代表的系統時間又叫做日曆時間,是真實世界的時間一致的。而我們知道地球上根據經度不同對於各地區的人來說時間也是不同,因此為了正常生活需要劃分出時區;各時區的時間不同,但某些事物會在不同的時區同時發生,因此又需要一個統一的標準時來確定時間,這句是協調世界時(UTC)。

從上面我們可以看到,表達日曆時間除了記錄時間跨度之外還需要儲存時區資訊,然而我們的time_t並沒有儲存時區(timezone)!這是因為標準庫把時區的設定交給了系統以及使用者自己,在標準庫裡受到支援的只有local timeUTC time

因此你會發現標準庫函式都對引數是何種時間,返回值是什麼時間做了明確的宣告。而我們的mktime接受的是_local time_而返回的是_UTC time_,所以time_t所表示的時間比我們預想的差了8小時。

所以我們在Linux上處理時間時一定要注意上下文中時間值附帶的時區資訊。

帶有完整日曆資訊的struct tm

time_t息息相關的要數struct tm了,它的宣告如下:

struct tm {
  int tm_sec;      /* 秒 [0-60] 允許有1秒的閏秒存在(c++11前和c99前允許2秒的閏秒,所以最大值是61) */
  int tm_min;      /* 分 [0-59] */
  int tm_hour;     /* 時 [0-23] */
  int tm_mday;     /* 日 [1-31] */
  int tm_mon;      /* 月,1月為0 */
  int tm_year;     /* 年份,從1900年開始計算,1970年的值為70 */
  int tm_wday;     /* 星期幾,星期天為0,星期六為6,依次遞增 */
  int tm_yday;     /* 一年中的第幾天,1月1日是0 */
  int tm_isdst;    /* 是否啟用夏令時 */
};

當然這只是標準給出的必須要有的成員,實際上在某些bsd系統中struct tm實際上還會包含時區相關的成員,為了寫出可移植的程式碼我們將這些附加內容視為不存在。

獲取struct tm除了像我們上一節那樣手動指定成員的值之外,還有若干標準庫函式可供使用:

// mktime不再贅述,它除了轉換tm到time_t之外還可以根據給出的欄位自動將tm設定成合理的值
// localtime 認為收到的是local time,返回該local time對應的tm值
// 注意t1複製了返回值,因為localtime,gmtime返回的是static生命週期的指標,無法保證它的值不會被修改
std::time_t now = std::time(nullptr);
std::tm t1 = *std::localtime(&now);

// gmtime 認為接受的是local time,返回將該local time轉換為UTC time之後的值
std::tm *t2 = std::gmtime(&now);

// difftime用於比較兩個time_t之間相差的秒數
auto time_end = mktime(&t1);
auto time_beg = mktime(t2);
std::cout << std::difftime(time_end, time_beg) << std::endl; // Output: 28800

正如上面程式碼所示,標準庫提供的函式gmtime, localtime, asctime, ctime都使用了函式內的static儲存,所以必要的情況下必須把結果值進行拷貝;或者你也可以使用posix提供的帶_r字尾的安全版本。

結果是28800秒,也就是8小時,我們所在的時區是UTC+8,符合預期。

此外我們還可以將tm進行格式化輸出:

// ctime將接收的time_t視為UTC time,將其轉換為local time之後再轉換成字串
// ctime相當於asctime(localtime(...))
std::time_t t1{}; // 預設初始化為0
std::cout << std::ctime(&t1) << std::endl;
// Output: Thu Jan  1 08:00:00 1970

// asctime將收到的tm資料原樣輸出,不做任何時區的轉換
std::tm tm1{};
tm1.tm_year = 70;
tm1.tm_mday = 1;
mktime(&tm1);
std::cout << std::asctime(&tm1) << std::endl;
// Output: Thu Jan  1 00:00:00 1970

此外我們還有strftimestrptime(需要#define _XOPEN_SOURCE)用來將tm格式化為字串和將字串解析為tm,限於篇幅我們不過多介紹。

在看過這些常用介面之後,我覺得你現在一定陷入混亂了,因為每個函式對時區的假設都不同,甚至一個函式的引數和返回值的時區也不相同!這就是為什麼在Linux上處理時間問題會成為噩夢的原因之一。

你可以靠下圖進行簡單的記憶,黃色線代表與時區無關,藍色代表不進行時區轉換,紅色代表轉換為local time,綠色則是UTC time:

至於local和UTC以外的時區怎麼辦。。。沒辦法,只能自己手動算時區偏移量了。

過時的timeval

timeval的宣告如下:

#include <sys/time.h>

struct timeval {
  time_t       tv_sec;  // 秒
  suseconds_t  tv_usec; // us 微秒
};

前面兩種方案精度只能到秒,而struct timeval可以儲存到微秒。timeval除了表示日期類似於time_t之外,還可以用來表示時間跨度(duration):

#include <sys/time.h> // included by time.h
#include <time.h>

struct timeval t;
(void)gettimeofday(&t, nullptr); // UTC

// 使用timeval作為時間長度
struct timeval wait_time = {1, 500000}; // 1.5秒
select(NFDS, read_fds, write_fds, err_fds, &wait_time);

gettimeofday的第二個引數是時區,然而在Linux和glibc上這個引數的實際意義是沒有被定義的,所以我們傳遞nullptr。

由於gettimeofday自身的原因,你通常無法獲取到足夠到微秒的精度,會存在些許的偏差。另外posix1.2008已經將gettimeofday標記為廢棄,因此我們不應該繼續使用這一api,因此這裡不做過多討論。

使用timeval結構的函式也少的可憐,只有selectpselect

更現代的timespec

timespec的宣告如下:

#include <time.h>

struct timespec {
  time_t  tv_sec;  // 秒
  long    tv_nsec; // 納秒
};

struct timespec是更現代的精度也更高的結構,精度達到了納秒。同時c11和c++17標準還將其納入了標準庫,因此它現在不再只是posix標準下的了。

獲得timespec有兩種途徑,首先是c和c++標準庫提供的方法,我們以c++為例(c的方法完全一樣):

std::timespec ts;
timespec_get(&ts, TIME_UTC);

這樣我們就獲得了現在的UTC時間的值。第二個引數標準目前只定義了TIME_UTC,所以現在還無法直接獲取其他時區的時間值。

獲取timespec的第二種方法就是使用posix的clock_gettime,它不僅能獲得自1970/1/1開始的時間,還可以自定義clock的型別以便獲取不同的時間值,現在是被推薦的用於獲取時間的介面,在需要獲取較高精度的時間值時應該優先考慮使用它:

#include <stdio.h>
#include <string.h>
#include <time.h>

void print_time(const char *id, const struct timespec *t)
{
    printf("%s:\nseconds: %ld nanoseconds: %ld\n\n", id, t->tv_sec, t->tv_nsec);
}

// 獲取不同時鐘的時間值並列印,不支援的時鐘型別會讓clock_gettime返回-1
// 你不應該模仿這個巨集,我只是單純在偷懶而已
#define get_clock(clk_id) \
    do { \
        if (clock_gettime(clk_id, &t) != 0) { \
            printf("this system doesn't support the " #clk_id "clock\n"); \
        } \
        print_time(#clk_id, &t); \
        memset(&t, 0, sizeof(struct timespec)); \
    } while (0)

int main(void)
{
    struct timespec t = {0, 0};
    // 日曆時間,UTC
    get_clock(CLOCK_REALTIME);

    // 單調時鐘時間,從系統啟動開始計算
    get_clock(CLOCK_MONOTONIC);

    // 類似單調時鐘,但是包含了系統休眠時經過的時間
    get_clock(CLOCK_BOOTTIME);
}

輸出如下:

```plain text
CLOCK_REALTIME:
seconds: 1585273480 nanoseconds: 594245824

CLOCK_MONOTONIC:
seconds: 12018 nanoseconds: 401860644

CLOCK_BOOTTIME:
seconds: 12018 nanoseconds: 401863344
```

因為我的系統並沒有休眠,所以BOOTTIME和MONOTONIC的值是相同的。

還有更多的時鐘型別,比如基於硬體的更快的單調時鐘和系統時鐘,記錄程序/執行緒消耗cpu時間的時鐘等,具體參見man pages。

timespec的應用也相當廣泛,在clock_nanosleepnanosleeppthread等系統呼叫和庫中都被廣泛使用。

比如在pthread中我們規定等待互斥鎖2.5秒,超時就重試或放棄:

struct timespec timeout;
clock_gettime(CLOCK_REALTIME, &timeout);
timeout.tv_sec += 2;
timeout.tv_nsec += 50000000;
pthread_mutex_timedlock(&mutex, &timeout);

從上面可以看出超時是根據系統時間進行判斷的,通過設定mutex是屬性,我們還可以使用更為準確的單調時鐘。

總結

本文我們介紹了c/c++標準庫以及Linux提供的time api一共兩套時間處理方案。

對於簡單的date time的處理和獲取time pointer,標準庫的功能就足夠了;而對於超時/延時任務以及需要更高精度時間的場合我們需要系統呼叫的幫助。

兩套api間可以在損失微秒/納秒精度的前提下進行轉換,因為tv_sec成員都是time_t型別的。

兩套api各有所長,然而都有一個缺點————無法處理時區。在不引入第三方庫和自己手動計算的情況下,Linux處理時區的手段只有以下兩種:

  1. 函式自己定義引數和返回值使用local time還是UTC time;
  2. 系統根據環境變數TZ以及配置檔案/etc/localtime等改變本地時間(local time)。

因此在處理時間時我們始終要注意當前被處理的時間是解釋成本地時間還是UTC時間;同時還要注意獲得的時間的本地還是UTC。因此時間處理問題不可避免的變得十分複雜,某些使用夏令時的地區這一問題還會被繼續放大。

當然,如果你不想用這麼底層的時間處理方法,還有類似xTime,libtai,boost_date_time這樣的第三方庫可以使用