摘要:如果現有子進程中的線程總數不能滿足負載,控制進程將派生新的子進程。為解決線程的并發問題,引入了線程安全資源管理器。的全拼,用來存放各個線程的鏈表。
PHP 進階之路 - 零基礎構建自己的服務治理框架(上)
PHP 進階之路 - 零基礎構建自己的服務治理框架(下)
PHP 進階之路 - 億級 pv 網站架構的技術細節與套路
PHP 進階之路 - 億級 pv 網站架構實戰之性能壓榨
注:本篇非我一己之力所完成,最后發布在了《TIPI》這本電子書上。
了解線程安全之前,我們先回顧幾點基礎知識點,是我們后面分析學習的基礎。
變量的作用域從作用域上來說,C語言可以定義4種不同的變量:全局變量,靜態全局變量,局部變量,靜態局部變量。
下面僅從函數作用域的角度分析一下不同的變量,假設所有變量聲明不重名。
全局變量(int gVar;),在函數外聲明。全局變量,所有函數共享,在任何地方出現這個變量名都是指這個變量。
靜態全局變量(static sgVar),其實也是所有函數共享,但是這個會有編譯器的限制,算是編譯器提供的一種功能。
局部變量(函數/塊內的int var;),不共享,函數的多次執行中涉及的這個變量都是相互獨立的,他們只是重名的不同變量而已。
局部靜態變量(函數中的static int sVar;),本函數間共享,函數的每一次執行中涉及的這個變量都是這個同一個變量。
上面幾種作用域都是從函數的角度來定義作用域的,可以滿足所有我們對單線程編程中變量的共享情況。 現在我們來分析一下多線程的情況。
在多線程中,多個線程共享除函數調用棧之外的其他資源。 因此上面幾種作用域從定義來看就變成了。
全局變量,所有函數共享,因此所有的線程共享,不同線程中出現的不同變量都是這同一個變量。
靜態全局變量,所有函數共享,也是所有線程共享。
局部變量,此函數的各次執行中涉及的這個變量沒有聯系,因此,也是各個線程間也是不共享的。
靜態局部變量,本函數間共享,函數的每次執行涉及的這個變量都是同一個變量,因此,各個線程是共享的。
線程安全資源管理器的由來在多線程系統中,進程保留著資源所有權的屬性,而多個并發執行流是執行在進程中運行的線程。 如 Apache2 中的 worker,主控制進程生成多個子進程,每個子進程中包含固定的線程數,各個線程獨立地處理請求。 同樣,為了不在請求到來時再生成線程,MinSpareThreads 和 MaxSpareThreads 設置了最少和最多的空閑線程數; 而 MaxClients 設置了所有子進程中的線程總數。如果現有子進程中的線程總數不能滿足負載,控制進程將派生新的子進程。
當 PHP 運行在如上類似的多線程服務器時,此時的 PHP 處在多線程的生命周期中。 在一定的時間內,一個進程空間中會存在多個線程,同一進程中的多個線程公用模塊初始化后的全局變量, 如果和 PHP 在 CLI 模式下一樣運行腳本,則多個線程會試圖讀寫一些存儲在進程內存空間的公共資源(如在多個線程公用的模塊初始化后的函數外會存在較多的全局變量)。
此時這些線程訪問的內存地址空間相同,當一個線程修改時,會影響其它線程,這種共享會提高一些操作的速度, 但是多個線程間就產生了較大的耦合,并且當多個線程并發時,就會產生常見的數據一致性問題或資源競爭等并發常見問題, 比如多次運行結果和單線程運行的結果不一樣。如果每個線程中對全局變量、靜態變量只有讀操作,而無寫操作,則這些個全局變量就是線程安全的,只是這種情況不太現實。
為解決線程的并發問題,PHP 引入了 TSRM: 線程安全資源管理器(Thread Safe Resource Manager)。 TRSM 的實現代碼在 PHP 源碼的 /TSRM 目錄下,調用隨處可見,通常,我們稱之為 TSRM 層。 一般來說,TSRM 層只會在被指明需要的時候才會在編譯時啟用(比如,Apache2+worker MPM,一個基于線程的MPM), 因為 Win32 下的 Apache 來說,是基于多線程的,所以這個層在 Win32 下總是被開啟的。
TSRM的實現進程保留著資源所有權的屬性,線程做并發訪問,PHP 中引入的 TSRM 層關注的是對共享資源的訪問, 這里的共享資源是線程之間共享的存在于進程的內存空間的全局變量。 當 PHP 在單進程模式下時,一個變量被聲明在任何函數之外時,就成為一個全局變量。
首先定義了如下幾個非常重要的全局變量(這里的全局變量是多線程共享的)。
/* The memory manager table */ static tsrm_tls_entry **tsrm_tls_table=NULL; static int tsrm_tls_table_size; static ts_rsrc_id id_count; /* The resource sizes table */ static tsrm_resource_type *resource_types_table=NULL; static int resource_types_table_size;
**tsrm_tls_table 的全拼 thread safe resource manager thread local storage table,用來存放各個線程的 tsrm_tls_entry 鏈表。
tsrm_tls_table_size 用來表示 **tsrm_tls_table 的大小。
id_count 作為全局變量資源的 id 生成器,是全局唯一且遞增的。
*resource_types_table 用來存放全局變量對應的資源。
resource_types_table_size 表示 *resource_types_table 的大小。
其中涉及到兩個關鍵的數據結構 tsrm_tls_entry 和 tsrm_resource_type。
typedef struct _tsrm_tls_entry tsrm_tls_entry; struct _tsrm_tls_entry { void **storage;// 本節點的全局變量數組 int count;// 本節點全局變量數 THREAD_T thread_id;// 本節點對應的線程 ID tsrm_tls_entry *next;// 下一個節點的指針 }; typedef struct { size_t size;// 被定義的全局變量結構體的大小 ts_allocate_ctor ctor;// 被定義的全局變量的構造方法指針 ts_allocate_dtor dtor;// 被定義的全局變量的析構方法指針 int done; } tsrm_resource_type;
當新增一個全局變量時,id_count 會自增1(加上線程互斥鎖)。然后根據全局變量需要的內存、構造函數、析構函數生成對應的資源tsrm_resource_type,存入 *resource_types_table,再根據該資源,為每個線程的所有tsrm_tls_entry節點添加其對應的全局變量。
有了這個大致的了解,下面通過仔細分析 TSRM 環境的初始化和資源 ID 的分配來理解這一完整的過程。
TSRM 環境的初始化模塊初始化階段,在各個 SAPI main 函數中通過調用 tsrm_startup 來初始化 TSRM 環境。tsrm_startup 函數會傳入兩個非常重要的參數,一個是 expected_threads,表示預期的線程數, 一個是 expected_resources,表示預期的資源數。不同的 SAPI 有不同的初始化值,比如mod_php5,cgi 這些都是一個線程一個資源。
TSRM_API int tsrm_startup(int expected_threads, int expected_resources, int debug_level, char *debug_filename) { /* code... */ tsrm_tls_table_size = expected_threads; // SAPI 初始化時預計分配的線程數,一般都為1 tsrm_tls_table = (tsrm_tls_entry **) calloc(tsrm_tls_table_size, sizeof(tsrm_tls_entry *)); /* code... */ id_count=0; resource_types_table_size = expected_resources; // SAPI 初始化時預先分配的資源表大小,一般也為1 resource_types_table = (tsrm_resource_type *) calloc(resource_types_table_size, sizeof(tsrm_resource_type)); /* code... */ return 1; }
精簡出其中完成的三個重要的工作,初始化了 tsrm_tls_table 鏈表、resource_types_table 數組,以及 id_count。而這三個全局變量是所有線程共享的,實現了線程間的內存管理的一致性。
資源 ID 的分配我們知道初始化一個全局變量時需要使用 ZEND_INIT_MODULE_GLOBALS 宏(下面的數組擴展的例子中會有說明),而其實際則是調用的 ts_allocate_id 函數在多線程環境下申請一個全局變量,然后返回分配的資源 ID。代碼雖然比較多,實際還是比較清晰,下面附帶注解進行說明:
TSRM_API ts_rsrc_id ts_allocate_id(ts_rsrc_id *rsrc_id, size_t size, ts_allocate_ctor ctor, ts_allocate_dtor dtor) { int i; TSRM_ERROR((TSRM_ERROR_LEVEL_CORE, "Obtaining a new resource id, %d bytes", size)); // 加上多線程互斥鎖 tsrm_mutex_lock(tsmm_mutex); /* obtain a resource id */ *rsrc_id = TSRM_SHUFFLE_RSRC_ID(id_count++); // 全局靜態變量 id_count 加 1 TSRM_ERROR((TSRM_ERROR_LEVEL_CORE, "Obtained resource id %d", *rsrc_id)); /* store the new resource type in the resource sizes table */ // 因為 resource_types_table_size 是有初始值的(expected_resources),所以不一定每次都要擴充內存 if (resource_types_table_size < id_count) { resource_types_table = (tsrm_resource_type *) realloc(resource_types_table, sizeof(tsrm_resource_type)*id_count); if (!resource_types_table) { tsrm_mutex_unlock(tsmm_mutex); TSRM_ERROR((TSRM_ERROR_LEVEL_ERROR, "Unable to allocate storage for resource")); *rsrc_id = 0; return 0; } resource_types_table_size = id_count; } // 將全局變量結構體的大小、構造函數和析構函數都存入 tsrm_resource_type 的數組 resource_types_table 中 resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].size = size; resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].ctor = ctor; resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].dtor = dtor; resource_types_table[TSRM_UNSHUFFLE_RSRC_ID(*rsrc_id)].done = 0; /* enlarge the arrays for the already active threads */ // PHP內核會接著遍歷所有線程為每一個線程的 tsrm_tls_entry for (i=0; icount < id_count) { int j; p->storage = (void *) realloc(p->storage, sizeof(void *)*id_count); for (j=p->count; j storage[j] = (void *) malloc(resource_types_table[j].size); if (resource_types_table[j].ctor) { // 最后對 p->storage[j] 地址存放的全局變量進行初始化, // 這里 ts_allocate_ctor 函數的第二個參數不知道為什么預留,整個項目中實際都未用到過,對比PHP7發現第二個參數也的確已經移除了 resource_types_table[j].ctor(p->storage[j], &p->storage); } } p->count = id_count; } p = p->next; } } // 取消線程互斥鎖 tsrm_mutex_unlock(tsmm_mutex); TSRM_ERROR((TSRM_ERROR_LEVEL_CORE, "Successfully allocated new resource id %d", *rsrc_id)); return *rsrc_id; }
當通過 ts_allocate_id 函數分配全局資源 ID 時,PHP 內核會先加上互斥鎖,確保生成的資源 ID 的唯一,這里鎖的作用是在時間維度將并發的內容變成串行,因為并發的根本問題就是時間的問題。當加鎖以后,id_count 自增,生成一個資源 ID,生成資源 ID 后,就會給當前資源 ID 分配存儲的位置,
每一個資源都會存儲在 resource_types_table 中,當一個新的資源被分配時,就會創建一個 tsrm_resource_type。
所有 tsrm_resource_type 以數組的方式組成 tsrm_resource_table,其下標就是這個資源的 ID。
其實我們可以將 tsrm_resource_table 看做一個 HASH 表,key 是資源 ID,value 是 tsrm_resource_type 結構(任何一個數組都可以看作一個 HASH 表,如果數組的key 值有意義的話)。
在分配了資源 ID 后,PHP 內核會接著遍歷所有線程為每一個線程的 tsrm_tls_entry 分配這個線程全局變量需要的內存空間。
這里每個線程全局變量的大小在各自的調用處指定(也就是全局變量結構體的大小)。最后對地址存放的全局變量進行初始化。
為此我畫了一張圖予以說明
上圖中 tsrm_tls_table 的元素是如何添加的,鏈表是如何實現的。我們把這個問題先留著,后面會討論。
每一次的 ts_allocate_id 調用,PHP 內核都會遍歷所有線程并為每一個線程分配相應資源,
如果這個操作是在PHP生命周期的請求處理階段進行,豈不是會重復調用?
PHP 考慮了這種情況,ts_allocate_id 的調用在模塊初始化時就調用了。
TSRM 啟動后,在模塊初始化過程中會遍歷每個擴展的模塊初始化方法,
擴展的全局變量在擴展的實現代碼開頭聲明,在 MINIT 方法中初始化。
其在初始化時會知會 TSRM 申請的全局變量以及大小,這里所謂的知會操作其實就是前面所說的 ts_allocate_id 函數。
TSRM 在內存池中分配并注冊,然后將資源ID返回給擴展。
以標準的數組擴展為例,首先會聲明當前擴展的全局變量。
ZEND_DECLARE_MODULE_GLOBALS(array)
然后在模塊初始化時會調用全局變量初始化宏初始化 array,比如分配內存空間操作。
static void php_array_init_globals(zend_array_globals *array_globals) { memset(array_globals, 0, sizeof(zend_array_globals)); } /* code... */ PHP_MINIT_FUNCTION(array) /* {{{ */ { ZEND_INIT_MODULE_GLOBALS(array, php_array_init_globals, NULL); /* code... */ }
這里的聲明和初始化操作都是區分ZTS和非ZTS。
#ifdef ZTS #define ZEND_DECLARE_MODULE_GLOBALS(module_name) ts_rsrc_id module_name##_globals_id; #define ZEND_INIT_MODULE_GLOBALS(module_name, globals_ctor, globals_dtor) ts_allocate_id(&module_name##_globals_id, sizeof(zend_##module_name##_globals), (ts_allocate_ctor) globals_ctor, (ts_allocate_dtor) globals_dtor); #else #define ZEND_DECLARE_MODULE_GLOBALS(module_name) zend_##module_name##_globals module_name##_globals; #define ZEND_INIT_MODULE_GLOBALS(module_name, globals_ctor, globals_dtor) globals_ctor(&module_name##_globals); #endif
對于非ZTS的情況,直接聲明變量,初始化變量;對于ZTS情況,PHP內核會添加TSRM,不再是聲明全局變量,而是用ts_rsrc_id代替,初始化時也不再是初始化變量,而是調用ts_allocate_id函數在多線程環境中給當前這個模塊申請一個全局變量并返回資源ID。其中,資源ID變量名由模塊名加global_id組成。
如果要調用當前擴展的全局變量,則使用:ARRAYG(v),這個宏的定義:
#ifdef ZTS #define ARRAYG(v) TSRMG(array_globals_id, zend_array_globals *, v) #else #define ARRAYG(v) (array_globals.v) #endif
如果是非ZTS則直接調用全局變量的屬性字段,如果是ZTS,則需要通過TSRMG獲取變量。
TSRMG的定義:
#define TSRMG(id, type, element) (((type) (*((void ***) tsrm_ls))[TSRM_UNSHUFFLE_RSRC_ID(id)])->element)
去掉這一堆括號,TSRMG宏的意思就是從tsrm_ls中按資源ID獲取全局變量,并返回對應變量的屬性字段。
那么現在的問題是這個 tsrm_ls 從哪里來的?
tsrm_ls 的初始化tsrm_ls 通過 ts_resource(0) 初始化。展開實際最后調用的是 ts_resource_ex(0,NULL) 。下面將 ts_resource_ex 一些宏展開,線程以 pthread 為例。
#define THREAD_HASH_OF(thr,ts) (unsigned long)thr%(unsigned long)ts static MUTEX_T tsmm_mutex; void *ts_resource_ex(ts_rsrc_id id, THREAD_T *th_id) { THREAD_T thread_id; int hash_value; tsrm_tls_entry *thread_resources; // tsrm_tls_table 在 tsrm_startup 已初始化完畢 if(tsrm_tls_table) { // 初始化時 th_id = NULL; if (!th_id) { //第一次為空 還未執行過 pthread_setspecific 所以 thread_resources 指針為空 thread_resources = pthread_getspecific(tls_key); if(thread_resources){ TSRM_SAFE_RETURN_RSRC(thread_resources->storage, id, thread_resources->count); } thread_id = pthread_self(); } else { thread_id = *th_id; } } // 上鎖 pthread_mutex_lock(tsmm_mutex); // 直接取余,將其值作為數組下標,將不同的線程散列分布在 tsrm_tls_table 中 hash_value = THREAD_HASH_OF(thread_id, tsrm_tls_table_size); // 在 SAPI 調用 tsrm_startup 之后,tsrm_tls_table_size = expected_threads thread_resources = tsrm_tls_table[hash_value]; if (!thread_resources) { // 如果還沒,則新分配。 allocate_new_resource(&tsrm_tls_table[hash_value], thread_id); // 分配完畢之后再執行到下面的 else 區間 return ts_resource_ex(id, &thread_id); } else { do { // 沿著鏈表逐個匹配 if (thread_resources->thread_id == thread_id) { break; } if (thread_resources->next) { thread_resources = thread_resources->next; } else { // 鏈表的盡頭仍然沒有找到,則新分配,接到鏈表的末尾 allocate_new_resource(&thread_resources->next, thread_id); return ts_resource_ex(id, &thread_id); } } while (thread_resources); } TSRM_SAFE_RETURN_RSRC(thread_resources->storage, id, thread_resources->count); // 解鎖 pthread_mutex_unlock(tsmm_mutex); }
而 allocate_new_resource 則是為新的線程在對應的鏈表中分配內存,并且將所有的全局變量都加入到其 storage 指針數組中。
static void allocate_new_resource(tsrm_tls_entry **thread_resources_ptr, THREAD_T thread_id) { int i; (*thread_resources_ptr) = (tsrm_tls_entry *) malloc(sizeof(tsrm_tls_entry)); (*thread_resources_ptr)->storage = (void **) malloc(sizeof(void *)*id_count); (*thread_resources_ptr)->count = id_count; (*thread_resources_ptr)->thread_id = thread_id; (*thread_resources_ptr)->next = NULL; // 設置線程本地存儲變量。在這里設置之后,再到 ts_resource_ex 里取 pthread_setspecific(*thread_resources_ptr); if (tsrm_new_thread_begin_handler) { tsrm_new_thread_begin_handler(thread_id, &((*thread_resources_ptr)->storage)); } for (i=0; istorage[i] = NULL; } else { // 為新增的 tsrm_tls_entry 節點添加 resource_types_table 的資源 (*thread_resources_ptr)->storage[i] = (void *) malloc(resource_types_table[i].size); if (resource_types_table[i].ctor) { resource_types_table[i].ctor((*thread_resources_ptr)->storage[i], &(*thread_resources_ptr)->storage); } } } if (tsrm_new_thread_end_handler) { tsrm_new_thread_end_handler(thread_id, &((*thread_resources_ptr)->storage)); } pthread_mutex_unlock(tsmm_mutex); }
上面有一個知識點,Thread Local Storage ,現在有一全局變量 tls_key,所有線程都可以使用它,改變它的值。
表面上看起來這是一個全局變量,所有線程都可以使用它,而它的值在每一個線程中又是多帶帶存儲的。這就是線程本地存儲的意義。
那么如何實現線程本地存儲呢?
需要聯合 tsrm_startup, ts_resource_ex, allocate_new_resource 函數并配以注釋一起舉例說明:
// 以 pthread 為例 // 1. 首先定義了 tls_key 全局變量 static pthread_key_t tls_key; // 2. 然后在 tsrm_startup 調用 pthread_key_create() 來創建該變量 pthread_key_create( &tls_key, 0 ); // 3. 在 allocate_new_resource 中通過 tsrm_tls_set 將 *thread_resources_ptr 指針變量存入了全局變量 tls_key 中 tsrm_tls_set(*thread_resources_ptr);// 展開之后為 pthread_setspecific(*thread_resources_ptr); // 4. 在 ts_resource_ex 中通過 tsrm_tls_get() 獲取在該線程中設置的 *thread_resources_ptr // 多線程并發操作時,相互不會影響。 thread_resources = tsrm_tls_get();
在理解了 tsrm_tls_table 數組和其中鏈表的創建之后,再看 ts_resource_ex 函數中調用的這個返回宏
#define TSRM_SAFE_RETURN_RSRC(array, offset, range) if (offset==0) { return &array; } else { return array[TSRM_UNSHUFFLE_RSRC_ID(offset)]; }
就是根據傳入 tsrm_tls_entry 和 storage 的數組下標 offset ,然后返回該全局變量在該線程的 storage數組中的地址。到這里就明白了在多線程中獲取全局變量宏 TSRMG 宏定義了。
其實這在我們寫擴展的時候會經常用到:
#define TSRMLS_D void ***tsrm_ls /* 不帶逗號,一般是唯一參數的時候,定義時用 */ #define TSRMLS_DC , TSRMLS_D /* 也是定義時用,不過參數前面有其他參數,所以需要個逗號 */ #define TSRMLS_C tsrm_ls #define TSRMLS_CC , TSRMLS_C
NOTICE 寫擴展的時候可能很多同學都分不清楚到底用哪一個,通過宏展開我們可以看到,他們分別是帶逗號和不帶逗號,以及申明及調用,那么英語中“D"就是代表:Define,而 后面的"C"是 Comma,逗號,前面的"C"就是Call。
以上為ZTS模式下的定義,非ZTS模式下其定義全部為空。
加個硬廣告 PHP 程序員技能包里該來點硬貨了! 最近老鐵開了直播,歡迎來捧場!PHP 進階之路 - 億級 pv 網站架構的技術細節與套路
PHP 進階之路 - 億級 pv 網站架構實戰之性能壓榨
PHP 進階之路 - 后端多元化之快速切入 Java 開發
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/23169.html
摘要:下面一起學習下鳥哥的框架。揭開神秘面紗采用客戶端服務器模式。在服務器端,進程保持睡眠狀態直到調用信息的到達為止。這和我們外網的原理不都一個樣么那么我們一起看看高大上的是怎么在玩。整個傳輸以二進制流的形式傳送。 各位老鐵在點贊、收藏的時候敢不敢報名小弟的直播分享,絕對有干貨,絕對有驚喜!一次早餐錢的投入,可能是薪資的翻倍,可能是視野的拓展! PHP 進階之路 - 億級 pv 網站架構...
摘要:在看這篇長輪詢之前可以先看看輪詢技術沒有長,有助于理解長輪詢屬于輪詢的升級版,在客戶端和服務端都進行了一些改造,使得消耗更低,速度更快。不間斷的通過查詢服務端。然后客戶端不間斷繼續發起請求數據不存在,繼續循環。 在看這篇Ajax長輪詢之前可以先看看Ajax輪詢技術(沒有長),有助于理解: Ajax長輪詢屬于Ajax輪詢的升級版,在客戶端和服務端都進行了一些改造,使得消耗更低,速度更快。...
摘要:解開多云戰略的神秘面紗組織今天需要采取的關鍵步驟是云行業正以閃電般的速度發展,企業采用多云技術由于需要更快的數字化轉型而日益成為主流。在多云環境中,規模和復雜性的挑戰只會增加。當一個組織缺乏對其多云基礎設施的可視性時,會出現許多問題。解開多云戰略的神秘面紗:組織今天需要采取的關鍵步驟是:云行業正以閃電般的速度發展,企業采用多云技術由于需要更快的數字化轉型而日益成為主流。Gartner預測,到...
摘要:解開多云戰略的神秘面紗組織今天需要采取的關鍵步驟是云行業正以閃電般的速度發展,企業采用多云技術由于需要更快的數字化轉型而日益成為主流。在多云環境中,規模和復雜性的挑戰只會增加。當一個組織缺乏對其多云基礎設施的可視性時,會出現許多問題。解開多云戰略的神秘面紗:組織今天需要采取的關鍵步驟是:云行業正以閃電般的速度發展,企業采用多云技術由于需要更快的數字化轉型而日益成為主流。Gartner預測,到...
摘要:解開多云戰略的神秘面紗組織今天需要采取的關鍵步驟是云行業正以閃電般的速度發展,企業采用多云技術由于需要更快的數字化轉型而日益成為主流。在多云環境中,規模和復雜性的挑戰只會增加。當一個組織缺乏對其多云基礎設施的可視性時,會出現許多問題。解開多云戰略的神秘面紗:組織今天需要采取的關鍵步驟是:云行業正以閃電般的速度發展,企業采用多云技術由于需要更快的數字化轉型而日益成為主流。Gartner預測,到...
閱讀 954·2021-11-25 09:43
閱讀 2291·2019-08-30 15:55
閱讀 3153·2019-08-30 15:44
閱讀 2053·2019-08-29 16:20
閱讀 1453·2019-08-29 12:12
閱讀 1609·2019-08-26 12:19
閱讀 2283·2019-08-26 11:49
閱讀 1712·2019-08-26 11:42