C++標準庫讀書筆記: Concurrency

由于多核的出現,使用多線程能夠顯著提高性能。
C++11之前,C++并沒有對并發提供語言層面的支持,C++標準庫也沒有。C++11之后:

  • 語言層面,定義一個內存模型,保證在兩個不同線程中對兩個對象的操作相互獨立,增加了 thread_local 關鍵字。
  • 標準庫提供了對開啟多線程、同步多線程的支持。

The High-Level Interface: async() and Futures


  • async()
    提供了接口讓一個可調用的對象(如某個函數)在獨立的線程中運行。
  • future<> 類
    允許等待某個線程完成,并訪問其結果。

一個使用 async() 以及 Future 的例子

計算 func1() + func2()
如果是單線程,只能依次運行,并把結果相加。總時間是兩者時間之和。
多核多線程情況下,如果兩者獨立,可以分別運行再相加。總時間是兩者時間的最大值。

#include <future>
#include <iostream>
#include <random> // for default_random_engine, uniform_int_distribution

using namespace std;

int doSomething (char c) {
    // random-number generator(use c as seed to get different sequences)
    std::default_random_engine dre(c);
    
    std::uniform_int_distribution<int> id(10,1000);


    for (int i=0; i<10; ++i) {
        this_thread::sleep_for(chrono::milliseconds(id(dre)));
        cout.put(c).flush();    // output immediately
    }
    return c;
}

int func1() {
    return doSomething('.');
}

int func2() {
    return doSomething('+');
}

int main() {
    cout << "start func1() in background, and func2() in foreground: " << endl;
    future<int> result1(std::async(func1));
    int result2 = func2();

    int result = result1.get() + result2;
    
    cout << "\nresult of func1()+func2(): " << result << endl;
}

注意到在主函數中,我們使用了如下步驟:

    // instead of:
    // int result = func1() + func2();

    future<int> result1(std::async(func1));
    int result2 = func2();
    int result = result1.get() + result2;

我們使用 async() 使 func1() 在別的線程運行,并將結果賦值給 future。func2() 繼續在主線程運行,最后綜合結果。
future 對象的作用體現在:

  1. 允許訪問 func1 產生的結果,可能是一個返回值也可能是一個異常。注意 future 是一個模板類,我們指定為 func1 的返回值類型 int。對于無返回值的類型我們可以聲明一個 std::future<void>
  2. 保證 func1 的執行。async() 僅僅是嘗試開始運行傳入的 functionality,如果并沒有運行,那在需要結果的時候,future 對象強制開始運行。

最后,我們需要使用到異步執行的函數的結果的時候,會使用 get()。如:

    int result = result1.get() + result2;

當我們使用 get() 的時候,可能發生以下三種情況:

  1. 如果 func1() 是使用 async() 在別的線程啟動的,而且已經運行結束,可以立即得到結果。
  2. 如果 func1() 啟動了但是還未結束,則 get() 會阻塞直到拿到結果。
  3. 如果 func1() 還未啟動,則會強制啟動,這時候就表現得像一個同步執行的程序。

可以看出,綜合使用

   std::future<int> result1(std::async(func1));
   result1.get()

使得無論是否允許多線程,程序都能順利完成。
為達到最佳使用效果,我們需要記住一條準則“call early and return late”,給予異步線程足夠的執行時間。
async() 的參數可以是任何可調用對象:函數,成員函數,函數對象,或者 lambda。注意 lambda 后面不要習慣性的加上小括號。

std::async([]{ ... }) // try to perform ... asynchronously

Using Launch Policies

有時候我們希望子線程立刻開始,而不要等待調度。那么我們就需要使用 lauch policy 來顯式指定,如果開始失敗,會拋出系統錯誤異常。

    // force func1() to start asynchronously now or throw std::system_error 
    std::future<long> result1 = std::async(std::launch::async, func1);

使用 std::launch::async 我們就不必再使用 get() 了,因為如果 result1 的生命周期結束了,程序會等待 func1 完成。因此,如果我沒有調用 get(),在退出 result1 的作用域時一樣會等待 func1 結束。然而,出于代碼的可讀性,推薦還是加上 get()。

同理,我們也可以指定子線程在 get() 時再運行。

    auto f1 = std::async(std::launch::deferred, func1);

Waiting and Polling

future 的 get() 方法只能被調用一次,在使用 get() 之后,future 實例就變為 invalid 的了。而 future 也提供了 wait() 方法,可以被調用多次,并且可以加上時限。其調用形式為:

std::future<...> f(std::async(func));
f.wait_for(std::chrono::seconds(10));  // wait at most 10 seconds
std::future<...> f(std::async(func));
f.wait_until(std::system_lock::now() + std::chrono::minites(1));  // wait until a specific timepoint has reached

這兩個函數的返回值一樣,有三種:

  • std::future_status::deferred
    func 還沒有開始執行。
  • std::future_status::timeout
    func 開始執行但是還沒完成
  • std::future_status::ready
    func 執行完畢

綜合使用 launch policy 和 wait 方法的例子如下,我們可以讓兩個線程分別進行計算,然后等待一定時間后輸出結果。

#include <cstdio>
#include <future>
#include <iostream>

// defined here, lifetime of accurateComputation() may be longer than
// bestResultInTime()
std::future<double> f_slow;

double accurateComputation() {
  std::cout << "Begin accurate computation..." << std::endl;
  std::this_thread::sleep_for(std::chrono::seconds(5));
  std::cout << "Yield accurate answer..." << std::endl;
  return 3.1415;
}

double quickComputation() {
  std::cout << "Begin quick computation..." << std::endl;
  std::this_thread::sleep_for(std::chrono::seconds(1));
  std::cout << "Yield quick answer..." << std::endl;
  return 3.14;
}

double bestResultInTime(int seconds) {
  auto tp = std::chrono::system_clock::now() + std::chrono::seconds(seconds);
  
  // 立即開始
  f_slow = std::async(std::launch::async, accurateComputation);
  // 這兩句順序不可交換
  double quick_result = quickComputation();
  std::future_status f_status = f_slow.wait_until(tp);

  if (f_status == std::future_status::ready) {
    return f_slow.get();
  } else {
    return quick_result;
  }
}

int main() {
  using namespace std::chrono;

  int timeLimit;
  printf("Input execute time (in seconds):\n");
  std::cin >> timeLimit;
  printf("Execute for %d seconds\n", timeLimit);
  auto start = steady_clock::now();
  std::cout << "Result: " << bestResultInTime(timeLimit) << std::endl;
  std::cout
      << "time elapsed: "
      << duration_cast<duration<double>>(steady_clock::now() - start).count()
      << std::endl;
}

// g++ -o async2_test async2.cpp -std=c++0x -lpthread

在上面程序中尤為值得注意有兩點。

  1. 我們沒有把 future 放在 bestResultInTime() 中。這是因為如果 future 是局部變量,退出 bestResultInTime() 時,future 的析構函數會阻塞直到產生結果。

  2. wait 方法會阻塞等待,也就是說如果順序不對,就會變成順序執行,而非并行。

  // 這兩句順序不可交換
  double quick_result = quickComputation();
  std::future_status f_status = f_slow.wait_until(tp);

如果交換,則會先等待直到 timepoint,然后再執行 quickComputation(),變成串行程序。

以上程序輸出結果為:

Input execute time (in seconds):
3
Execute for 3 seconds
Begin quick computation...
Begin accurate computation...
Yield quick answer...
Result: 3.14
time elapsed: 3.00111
Yield accurate answer...

非常值得注意的是 main 函數結束后并沒有立刻退出,而是等待 func 執行完畢后 future 析構。

給 wait_for() 方法傳入 0,相當于立即獲取 future 的狀態。可以利用這點來得知某個任務 現在 是否開始了,或者是否還在運行。

The Low-Level Interface: Threads and Promises


C++ 標準庫也提供了底層接口來啟動和處理線程,我們可以先定義一個線程對象,以一個可調用對象來初始化,然后等待或者detach。

  void doSomething();
  std::thread t(doSomething);  // start doSomething in the background
  t.join();  // wait for t  to finish (block until doSomething() ends)

如同 async() 一樣,我們可以用任何可調用對象來初始化。但是作為一個底層的接口,一些在 async() 中的特性是不能使用的。

  • thread 沒有 launch policy。它總是立即開啟新線程運行 func。類似于使用了 std::launch::async。
  • 沒有用于處理結果的接口。我們能獲得的只有 thread ID(利用 get_id() 方法)。
  • 如果出現異常,程序會立即停止。
  • 我們需要聲明我們是需要等待 thread 運行結束(使用join()),或者讓它自己在別的線程運行。(使用detach())
  • 如果 main() 函數結束后,線程還在運行,則所有線程都會強制地結束。(future 會等待結束再析構)

以下例子顯示了 join 和 detach 的區別。
我們新建了一個線程輸出 +,另外5個線程 detach 輸出字母。按任意鍵將輸出 + 的線程 join。程序在等待 + 打印結束后,就會停止,不管 detach 線程是否完成了任務。

#include <exception>
#include <iostream>
#include <random>
#include <thread>

void doSomething(int num, char c) {
  try {
    std::default_random_engine dre(c);
    std::uniform_int_distribution<int> distribution(10, 1000);
    for (int i = 0; i < num; i++) {
      std::this_thread::sleep_for(std::chrono::milliseconds(distribution(dre)));
      std::cout.put(c).flush();
    }
  } catch (const std::exception &e) {
    std::cerr << "THREAD-EXCEPTION (thread " << std::this_thread::get_id()
              << e.what() << std::endl;
  } catch (...) {
    std::cerr << "THREAD-EXCEPTION (thread " << std::this_thread::get_id()
              << ")" << std::endl;
  }
}

int main() {
  try {
    std::thread t1(doSomething, 5, '+');
    std::cout << "- started fg thread " << t1.get_id() << std::endl;

    for (int i = 0; i < 5; i++) {
      std::thread t(doSomething, 10, 'a' + i);
      std::cout << "- detach started bg thread " << t.get_id() << std::endl;
      t.detach();
    }

    std::cin.get();

    std::cout << "- join fg thread " << t1.get_id() << std::endl;
    t1.join();
  } catch (const std::exception &e) {
    std::cerr << "EXCEPTION: " << e.what() << std::endl;
  }
}

使用 detached 線程的時候,一定要注意盡量避免使用非局部變量。也就是說,它所使用的變量都要和它的生命周期相同。因為我們 detach 之后就丟失了對它的控制權,不能保證它會對其他線程中的數據做什么更改。盡量使用傳值,而不要傳引用
對于 static 和 global 變量,我們無法阻止 detached 線程使用他們。如果我們已經銷毀了某個 static 變量和 global 變量,detached 線程仍然在訪問,那就會出現 undefined behavior。
在我們上面的程序中,detached 線程訪問了 std::cin, std::cout, std::cerr 這些全局的流,但是這些訪問時安全的,因為這些流會持續直到程序結束。然而,其他的全局變量不一定能保證。

Promises

現在我們需要考慮一個問題:如何才能在線程之間傳遞參數,以及處理異常。這也是上層的接口,例如 async() 的實現需要考慮的。當然,我們可以簡單地進行處理,需要參數則傳入參數,需要返回值則傳入一個引用。
但是,如果我們需要獲取函數的返回值或者異常,那我們就需要用到 std::promise 了。它就是對應于 future 的底層實現。我們可以利用 set_value() 以及set_exception() 方法來設置 promise 的值。

#include <future>
#include <iostream>
#include <thread>

void doSomething(std::promise<std::string> &p) {
  try {
    std::cout << "read char ('x' for exception): ";
    char c = std::cin.get();
    if (c == 'x') {
      throw std::runtime_error(std::string("char ") + c + " read");
    }
    std::string s = std::string("char ") + c + " processed";
    p.set_value(std::move(s)); // use move to avoid copying
  } catch (...) {
    p.set_exception(std::current_exception());
  }
}

int main() {
  try {
    std::promise<std::string> p;
    std::thread t(doSomething, std::ref(p));
    t.detach();

    std::future<std::string> f(p.get_future());
    std::cout << "result: " << f.get() << std::endl;
  } catch (const std::exception &e) {
    std::cerr << "EXCEPTION: " << e.what() << std::endl;
  } catch (...) {
    std::cerr << "EXCEPTION " << std::endl;
  }
}

以上程序定義了一個 promise,用這個 promise 初始化了一個 future,并在一個 detached 的線程之中為其賦值(可能返回 string 也可能是個 exception)。賦值過后,future 的狀態會變成 ready。然后調用 get() 獲取。

Synchronizing Threads


使用多線程的時候,往往都伴隨著并發的數據訪問,很少有線程相互獨立的情況。

The only safe way to concurrently access the same data by multiple threads without synchronization is when ALL threads only READ the data.

然而,當多個線程訪問同一個變量,并且至少一個線程會對它作出更改時,就必須同步了。這就叫做 data race。定義為“不同線程中的兩個沖突的動作,其中至少一個動作是非原子的,兩個動作同時發生”。
編程語言,例如C++,抽象化了不同的硬件和平臺。因此,會有一個標準來指定語句和操作的作用,而不是每個語句具體生成什么匯編指令。也就是說,這些標準指定了 what,而不是 how。
例如,函數參數的 evaluation 順序就是未指明的。編譯器可以按照任何順序對操作數求值,也可以在多次求值同一個表達式時選擇不同的順序。
因此,編譯器幾乎是一個黑箱,我們得到的只是外表看起來一致的程序。編譯器可能會展開循環,整理表達式,去掉 dead code 等它認為的“優化”。
C++ 為了給編譯器和硬件預留了足夠的優化空間,并沒有給出一些你期望的保證。我們可能會遇到如下的問題:

  • Unsynchronized data access
    如果兩個線程并行讀寫同樣的數據,并不保證哪條語句先執行。
    例如,下面的程序在單線程中確保了使用 val 的絕對值:
  if (val >= 0) {
    f(val);
  } else {
    f(-val);
  }

但是在多線程中,就不一定能正常工作,val 的值可能在判斷后改變。

  • Half-written data
    如果一個線程讀取數據,另一個線程修改,讀取的線程可能會在寫入的同時讀取,讀到的既不是新數據,也不是老數據,而是不完整的修改中的數據。
    例如,我們定義如下變量:
long long x = 0;

新開一個線程 t1 更改變量的值:

x = -1;

在其他線程 t2 讀取:

std::cout << x;

那么我們可能得到:

  • 0(舊值),如果 t1 還沒有賦值
  • -1(新值),如果 t1 完成了賦值
  • 其他值,如果 t2 在 t1 賦值的時候讀取

這里解釋一下第三類情況,假設已在一個 32 位機器上,存儲需要 2 個單位,假設第一個單位已經被更改,第二個單位還沒有更改,然后 t2 開始讀取,就會出現其他值。
這類情況不止發生在 long long 類型上,即使是基礎類型,C++ 標準也沒有保證讀寫是原子操作。

  • Reordered statements
    表達式和操作有可能會被改變順序,因此單線程運行可能沒問題,但是多線程運行,就會出錯。
    假設我們需要在兩個線程之中共享一個 long,使用一個 bool 標志數據是否準備好。
// 定義
long data;
bool readyFlag = false;
// 線程 A
data = 42;  // 1
readyFlag = true;  // 2
// 線程 B
while (!readyFlag) {
  ;
}
foo(data);

粗一看似乎沒有什么問題,整個程序只有在線程 A 給 data 賦值后,readyFlag 才會變 true。
以上代碼的問題在于,如果編譯器改變了 1,2 的語句順序(這是允許的,因為編譯器只保證在 一個 線程中的執行是符合預期的),那么就會出現錯誤。

The Features to Solve the Problems


為了解決上述問題,我們需要下面的幾個概念。

  • **Atomicity: ** 不被中斷地、獨占地讀寫變量。其他進程無法讀取到中間態。
  • **Order: ** 確保某些語句的順序不被改變。

C++ 標準庫提供了不同的方案來處理。

  • 可以使用 future 以及 promise 來同時保證 atomicity 以及 order,它保證了先設置 outcome,再處理 outcome,表明了讀寫肯定是不同步的。

  • 使用 mutexlock 來處理臨界區(critical section)。只有得到鎖的線程才能執行代碼。

  • 使用條件變量,使進程等待其他進程控制的斷言變為 true。

Mutexes and Locks


互斥量是通過提供獨占的訪問來控制資源的并發訪問的對象。為了實現獨占訪問,對應的進程 “鎖住” 互斥量,阻止別的進程訪問直到 “解鎖”。
一般使用 mutex 的時候,可能會這么使用:

int val
std::mutex valMutex;
// Thread A
valMutex.lock();
if (val >= 0) {
  f(val);
} else {
  f(-val);
}
valMutex.unlock();
// Thread B
valMutex.lock();
++val;
valMutex.unlock();

看起來似乎能夠正常運行,但是如果 f(val) 中出現 exception,那么 unlock() 就不會執行,資源會被一直鎖住。

lock_guard

為解決這個問題,C++ 標準庫提供了一個在析構時能夠釋放鎖的類型:std::lock_guard。實現了類似于 Golang 中的 defer mu.unlock() 的功能。上面的例子可以改進為:

// Thread A
...
{  //新的scope
  std::lock_guard<std::mutex> lg(valMutex);
  if (val >= 0) {
    f(val);
  } else {
    f(-val);
  }
}
// Thread B
{
  std::lock_guard<std::mutex> lg(valMutex);
  ++val;
}

需要注意新開了一個作用域。確保 lg 能在合適的地方析構。
再看一個完整的例子。

#include <future>
#include <mutex>
#include <iostream>
#include <string>

std::mutex printMutex;

void print(const std::string& str) {
  std::lock_guard<std::mutex> lg(printMutex);  // lg 初始化時自動鎖定
  for (char c : str) {
    std::cout.put(c);
  }
  std::cout << std::endl;
}  // lg析構時自動解鎖

int main() {
  auto f1 = std::async(std::launch::async, print, "Hello from first thread");
  auto f2 = std::async(std::launch::async, print, "Hello from second thread");
  print("Hello from main thread");
}

如果不加鎖會亂序打印。

unique_lock

有時候,我們并不希望在鎖初始化的同時就上鎖。C++ 還提供了 unique_lock<> 類,它與 lock_guard<> 接口一致,但是它允許程序顯式地決定什么時候、怎樣上鎖和解鎖。它還提供了 owns_lock() 方法來查詢是否上鎖。使用更佳靈活,最常用的場景就是配合條件變量使用。具體例子在后面介紹 condition_variable 的部分。

處理多個鎖

這個的處理多個鎖并不是說挨個上鎖,而是假設一個線程執行同時需要用到多個資源,應該要么一起鎖上,要么全都不鎖。否則很容易出現死鎖。
例如線程 A 和 B 都需要鎖 m1 和 m2,而線程 A 獲得了 m1,在請求 m2,而線程 B 獲得了 m2,在請求 m1,這時候就會相互等待,發生死鎖。
C++ 標準庫提供了 lock() 函數以解決上述問題。它的功能簡單來講就是要么都鎖,要么都不鎖。
以下實現了一個簡單的銀行轉帳的例程。

#include <mutex>
#include <thread>
#include <iostream>

struct Bank_account {
    explicit Bank_account(int Balance) : balance(Balance) {}
    int balance;
    std::mutex mtx;
};

void transfer(Bank_account& from, Bank_account& to, int amount) {
/*
    // std::adopt_lock 假設已經上過鎖,在初始化時不會再加鎖,但是保留了析構釋放鎖的功能
    std::lock(from.mtx, to.mtx);
    std::lock_guard<std::mutex> lg1(from.mtx, std::adopt_lock);
    std::lock_guard<std::mutex> lg2(to.mtx, std::adopt_lock);
*/

// equivalent approach:
    std::unique_lock<std::mutex> ulock1(from.mtx, std::defer_lock);
    std::unique_lock<std::mutex> ulock2(to.mtx, std::defer_lock);
    std::lock(ulock1, ulock2);  // 這里鎖的是封裝過后的 mutex

    from.balance -= amount;
    to.balance += amount;
}

int main() {
    Bank_account a(100);
    Bank_account b(30);

    // 注意使用 std::ref
    std::thread t1(transfer, std::ref(a), std::ref(b), 20);
    std::thread t2(transfer, std::ref(b), std::ref(a), 10);


    t1.join();
    t2.join();
    std::cout << " a now has " << a.balance << ", b now has " << b.balance << std::endl;
}
    

以上例程有三個重點。已經在程序中中文標注。
重點講一下 std::ref,這里如果不加 std::ref 會報錯。說一下個人理解。
雖然這里已經在函數中說明了是引用:

void transfer(Bank_account& from, Bank_account& to, int amount);

但是我們實際是傳參數給了 std::thread 類的構造函數。它是一個模板類,默認肯定是進行值傳遞的。因此我們有必要在這里聲明是引用傳遞,這樣可以將模板改為引用。例如

template <typename T>
void foo (T val);

如果我們使用

int x;
foo (std::ref(x));

則模板類自動以 int& 為參數類型。
具體可以參見 std::reference_wrapper<> 類。

Condition Variables


有時,不同線程運行的任務可能需要互相等待。因此,除了訪問同一個數據,我們還有其他使用同步的場景,即邏輯上的依賴關系。
你可能會認為我們已經介紹過了這種機制:Futures 允許我們阻塞直到另一個線程執行結束。但是,Future 實際上是設計來處理返回值的,在這個場景下使用并不方便。
這里我們將介紹條件變量,它可以用于同步線程間的邏輯依賴。

在引入條件變量之前,為了實現這個功能,只能采用輪詢的方法,設置一個時間間隔,不斷去檢查。例如:

bool readyFlag;
std::mutex readyFlagMutex; // wait until readyFlag is true:
{
  std::unique_lock<std::mutex> ul(readyFlagMutex);
  while (!readyFlag) {
    ul.unlock();
    std::this_thread::yield(); // hint to reschedule to the next thread
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    ul.lock();
  }
} // release lock

這顯然不是一個好的方案,因為時間間隔設置太短和太長都不行。但是它表現了條件變量的基本思想。就是在未滿足條件時,放棄對鎖和 cpu 資源的占有,讓別的線程運行,等待條件滿足跳出 while。但是條件變量沒有采取輪詢而是采用一個信號通知。
一個典型應用是消費者-生產者模型。

#include <condition_variable>
#include <mutex>
#include <queue>
#include <thread>
#include <iostream>
#include <future> // for async

std::queue<int> q;
std::mutex mu;
std::condition_variable condVar;

void provider(int val) {
    for (int i=0; i<6; i++) {
        {
            std::lock_guard<std::mutex> lg(mu);
            q.push(val + i);
        }
        condVar.notify_one();
        std::this_thread::sleep_for(std::chrono::milliseconds(val));
    }
}

void consumer(int id) {
    while (true) {
        int val;
        {
            std::unique_lock<std::mutex> ul(mu);
            condVar.wait(ul, [](){return !q.empty();});
            val = q.front();
            q.pop();
        }
        std::cout << "consumer " << id << ": " << val << std::endl;
    }
}

int main() {
    // 3 個生產者
    auto p1 = std::async(std::launch::async, provider, 100);
    auto p2 = std::async(std::launch::async, provider, 300);
    auto p3 = std::async(std::launch::async, provider, 500);
    // 2 個消費者
    auto c1 = std::async(std::launch::async, consumer, 1);
    auto c2 = std::async(std::launch::async, consumer, 2);
}

Atomic

這是實現 lock-free 的重要類型。非常值得深入了解。
atomic 的效率比鎖要快很多,在 linux 下大概快 6 倍。
具體應用可以看我的 github 項目:使用mmap實現文件極速無鎖并行寫入
書中有一處錯誤:

atomic.png

經過實驗,實際上返回的并不是 new value 而是 previous value。具體可以參考cppreference

而且還能用于實現 spinlock

附錄


補充:C++11 中的隨機數生成方法

關于為什么要引入新的隨機數生成方法,參考這里
標準流程如下所示。

std::random_device rd;  // 隨機數種子 generator
std::default_random_engine e(rd());  // 原始隨機數 generator
std::uniform_int_distribution<> u(5,20);  // 在 [5,20] 上的均勻分布

for ( size_t i = 0 ; i < 10 ; i ++ ) {
     cout << u ( e ) << endl ;  // 迫使原始隨機數服從規定的分布
}

一個簡單的例子,觀察其轉化關系。

#include <iostream>
#include <random> // for default_random_engine, uniform_int_distribution

using namespace std;

void randomPrint () {
    // random-number generator(use c as seed to get different sequences)
    std::random_device rd;
    std::default_random_engine dre(rd());
    
    std::uniform_int_distribution<int> id(10,100);
    for (int i=0; i<10; i++) {
        // random test
        cout << dre() << " => " <<id(dre) << endl;
    }
}

int main() {
    randomPrint();
}

輸出為:

1337774351 => 49
354763686 => 93
1223972804 => 56
827960471 => 33
54361696 => 94
540202105 => 51
90156615 => 84
1553865488 => 64
780656749 => 21
134206787 => 74

更加詳細的可參考這里

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容