最近這幾天在幫檸檬看她的APM系統要如何收集.Net運行時的各類事件, 這些事件包括線程開始, JIT執行, GC觸發等等.
.Net在windows上(NetFramework, CoreCLR)經過ETW(Event Tracing for Windows), 在linux上(CoreCLR)是經過LTTng跟蹤事件.html
ETW的API設計已經被不少人詬病, 微軟推出的類庫krabsetw中直指ETW是最差的API而且把操做ETW的文件命名爲噩夢.hpp.
並且這篇文章中, Casey Muratori解釋了爲何ETW是最差的API, 緣由包括:python
然而Casey Muratori的文章對我幫助很大, 我只用了1天時間就寫出了使用ETW收集.Net運行時事件的示例代碼.
以後我開始看如何使用LTTng收集這些事件, 按照我以往的經驗linux上的類庫api一般會比windows的好用, 但LTTng是個例外.linux
我第一件作的事情是去查找怎樣在c程序裏面LTTng的接口, 我打開了他們的文檔而後開始瀏覽.
很快我發現了他們的文檔只談了如何使用代碼發送事件, 卻沒有任何說明如何用代碼接收事件, 我意識到我應該去看源代碼.ios
使用LTTng跟蹤事件首先須要建立一個會話, 啓用事件和添加上下文參數, 而後啓用跟蹤, 在命令行裏面是這樣的調用:git
lttng create --live lttng enable-event --userspace --tracepoint DotNETRuntime:GCStart_V2 lttng add-context --userspace --type vpid lttng add-context --userspace --type vtid lttng start
lttng這個命令的源代碼在github上, 經過幾分鐘的查找我發現lttng的各個命令的實現都是保存在這個文件夾下的.
打開create.c後又發現了建立會話調用的是lttng_create_session
函數, 而lttng_create_session
函數能夠經過引用lttng.h調用.
再過了幾分鐘我寫出了第一行代碼程序員
int ret = lttng_create_session_live("example-session", "net://127.0.0.1", 1000000);
運行後馬上就報錯了, 錯誤是"No session daemon is available".
緣由是lttng-sessiond
這個程序沒有啓動, lttng是經過一個獨立服務來管理會話的, 而這個服務須要手動啓動.github
使用獨立服務自己沒有錯, 可是lttng-sessiond
這個程序提供了不少參數,
若是一個只想跟蹤用戶事件的程序啓動了這個服務並指定了忽略內核事件的參數, 而後另一個跟蹤內核事件的程序將不能正常運做.
正確的作法是使用systemd來啓動這個服務, 讓系統管理員決定用什麼參數, 而不是讓調用者去啓動它.數據庫
解決這個問題只須要簡單粗暴的兩行, 啓動時若是已經啓動過新進程會失敗, 沒有任何影響:apache
system("lttng-sessiond --daemonize"); std::this_thread::sleep_for(std::chrono::seconds(1));
如今lttng_create_session_live
會返回成功了, 可是又發現了新的問題,
建立的會話是由一個單獨的服務管理的, 即便當前進程退出會話也會存在, 第二次建立的時候會返回一個已存在的錯誤.
這個問題和ETW的問題如出一轍, 解決方法也如出一轍, 在建立會話前關閉它就能夠了.json
因而代碼變成了這樣:
system("lttng-sessiond --daemonize"); std::this_thread::sleep_for(std::chrono::seconds(1)); lttng_destroy_session(SessionName); int ret = lttng_create_session_live("example-session", "net://127.0.0.1", 1000000);
通過一段時間後, 我用代碼實現了和命令行同樣的功能:
// start processes, won't replace exists system("lttng-sessiond --daemonize"); std::this_thread::sleep_for(std::chrono::seconds(1)); // create new session lttng_destroy_session(SessionName); int ret = lttng_create_session_live(SessionName, SessionUrl, LiveSessionInterval); if (ret != 0) { std::cerr << "lttng_create_session: " << lttng_strerror(ret) << std::endl; return -1; } // create handle from session lttng_domain domain = {}; domain.type = LTTNG_DOMAIN_UST; lttng_handle* handle = lttng_create_handle(SessionName, &domain); if (handle == nullptr) { std::cerr << "lttng_create_handle: " << lttng_strerror(ret) << std::endl; return -1; } // enable event lttng_event event = {}; event.type = LTTNG_EVENT_TRACEPOINT; memcpy(event.name, EventName.c_str(), EventName.size()); event.loglevel_type = LTTNG_EVENT_LOGLEVEL_ALL; event.loglevel = -1; ret = lttng_enable_event_with_exclusions(handle, &event, nullptr, nullptr, 0, nullptr); if (ret < 0) { std::cerr << "lttng_enable_event_with_exclusions: " << lttng_strerror(ret) << std::endl; return -1; } // add context lttng_event_context contextPid = {}; contextPid.ctx = LTTNG_EVENT_CONTEXT_VPID; ret = lttng_add_context(handle, &contextPid, nullptr, nullptr); if (ret < 0) { std::cerr << "lttng_add_context: " << lttng_strerror(ret) << std::endl; return -1; } // start tracing ret = lttng_start_tracing(SessionName); if (ret < 0) { std::cerr << "lttng_start_tracing: " << lttng_strerror(ret) << std::endl; return -1; }
到這裏爲止是否是很簡單? 儘管沒有文檔, 可是這些api都是很是簡單的api, 看源代碼就能夠推測如何調用.
在告訴LTTng啓用跟蹤後, 我還須要獲取發送到LTTng的事件, 在ETW中獲取事件是經過註冊回調獲取的:
EVENT_TRACE_LOGFILE trace = { }; trace.LoggerName = (char*)mySessionName.c_str(); trace.EventRecordCallback = (PEVENT_RECORD_CALLBACK)(StaticRecordEventCallback); trace.BufferCallback = (PEVENT_TRACE_BUFFER_CALLBACK)(StaticBufferEventCallback); trace.ProcessTraceMode = PROCESS_TRACE_MODE_EVENT_RECORD | PROCESS_TRACE_MODE_REAL_TIME; TRACEHANDLE sessionHandle = ::OpenTrace(&trace); if (sessionHandle == INVALID_PROCESSTRACE_HANDLE) { // ... } ULONG processStatus = ::ProcessTrace(&sessionHandle, 1, nullptr, nullptr);
我尋思lttng有沒有這樣的機制, 首先我看到的是lttng.h中的lttng_register_consumer
函數, 這個函數的註釋以下:
This call registers an "outside consumer" for a session and an lttng domain. No consumer will be spawned and all fds/commands will go through the socket path given (socket_path).
翻譯出來就是給會話註冊一個外部的消費者, 聽上去和個人要求很像吧?
這個函數的第二個參數是一個字符串, 我推測是unix socket, lttng會經過unix socket發送事件過來.
因而我寫了這樣的代碼:
ret = lttng_register_consumer(handle, "/tmp/custom-consumer");
一執行馬上報錯, 錯誤是Command undefined
, 也就是命令未定義, 服務端不支持這個命令.
通過搜索發現lttng的源代碼中沒有任何調用這個函數的地方, 也就是說這個函數是個裝飾.
看起來這個辦法行不通.
通過一番查找, 我發現了live-reading-howto這個文檔, 裏面的內容很是少可是能夠看出使用lttng-relayd
這個服務能夠讀取事件.
讀取事件目前只支持TCP, 使用TCP傳輸事件數據不只複雜並且效率很低, 相對ETW直接經過內存傳遞數據這無疑是個愚蠢的辦法.
雖然愚蠢可是仍是要繼續寫, 我開始看這TCP傳輸用的是什麼協議.
對傳輸協議的解釋文檔在live-reading-protocol.txt, 這篇文檔寫的很糟糕, 但總比沒有好.
和lttng-relayd
進行交互使用的是一個lttng本身創造的半雙工二進制協議, 設計以下:
客戶端發送命令給lttng-relayd
須要聽從如下的格式
[data_size: unsigned 64 bit big endian int, 命令體大小] [cmd: unsigned 32 bit big endian int, 命令類型] [cmd_version: unsigned 32 bit big endian int, 命令版本] [命令體, 大小是data_size]
發送命令的設計沒有問題, 大部分二進制協議都是這樣設計的, 問題在於接收命令的設計.
接收命令的格式徹底依賴於發送命令的類型, 例如LTTNG_VIEWER_CONNECT
這個命令發送過去會收到如下的數據:
[viewer_session_id: unsigned 64 bit big endian int, 服務端指定的會話ID] [major: unsigned 32 bit big endian int, 大版本] [minor: unsigned 32 bit big endian int, 中版本] [type: 客戶端的類型]
能夠看出接收的數據沒有數據頭, 沒有數據頭如何決定接收多少數據呢? 這就要求客戶端定義的迴應大小必須和服務端徹底一致, 一個字段都不能漏.
服務端在之後的更新中不能給返回數據隨意添加字段, 返回多少字段須要取決於發送過來的cmd_version
, 保持api的兼容性將會很是的麻煩.
目前在lttng中cmd_version
是一個預留字段, 也就是他們沒有仔細的想過api的更新問題.
正確的作法應該是返回數據也應該提供一個數據頭, 而後容許客戶端忽略多出來的數據.
看完協議之後, 我在想既然使用了二進制協議, 應該也會提供一個sdk來減小解析的工做量吧?
通過一番查找找到了一個頭文件lttng-viewer-abi.h, 包含了和lttng-relayd
交互使用的數據結構體定義.
這個頭文件在源代碼裏面有, 可是卻不在LTTng發佈的軟件包中, 這意味着使用它須要複製它到項目裏面.
複製別人的源代碼到項目裏面不能那麼隨便, 看了一下LTTng的開源協議, 在include/lttng/*
和src/lib/lttng-ctl/*
下的文件是LGPL, 其他文件是GPL,
也就是上面若是把這個頭文件複製到本身的項目裏面, 本身的項目必須使用GPL協議開源, 不想用GPL的話只能把裏面的內容本身一行行從新寫, 還不能寫的太像.
既然是測試就無論這麼多了, 把這個頭文件的代碼複製過來就開始繼續寫, 首先是鏈接到lttng-relayd
:
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd < 0) { perror("socket"); return -1; } sockaddr_in address = {}; address.sin_addr.s_addr = inet_addr("127.0.0.1"); address.sin_family = AF_INET; address.sin_port = htons(5344); ret = connect(fd, (sockaddr*)&address, sizeof(address)); if (ret < 0) { perror("connect"); return -1; }
鏈接成功之後的交互流程在閱讀上面的協議文檔之後能夠整理以下:
初始化 客戶端發送命令 LTTNG_VIEWER_CLIENT_COMMAND + 構造體 lttng_viewer_connect 服務端返回構造體 lttng_viewer_connect 客戶端發送命令 LTTNG_VIEWER_CREATE_SESSION + 構造體 lttng_viewer_create_session_response 服務端返回構造體 lttng_viewer_create_session_response 列出會話 客戶端發送命令 LTTNG_VIEWER_LIST_SESSIONS, 不帶構造體 服務端返回構造體 lttng_viewer_list_sessions + 指定長度的 lttng_viewer_session 附加到會話 客戶端發送命令 LTTNG_VIEWER_ATTACH_SESSION + 構造體 lttng_viewer_attach_session_request 服務端返回構造體 lttng_viewer_attach_session_response + 指定長度的 lttng_viewer_stream 循環 { 若是須要獲取新的流 { 客戶端發送命令 LTTNG_VIEWER_GET_NEW_STREAMS + 構造體 lttng_viewer_new_streams_request 服務端返回構造體 lttng_viewer_new_streams_response + 指定長度的 lttng_viewer_stream } 若是須要獲取新的元數據(metadata) { 枚舉現存的metadata流列表 { 客戶端發送命令 LTTNG_VIEWER_GET_METADATA + 構造體 lttng_viewer_get_metadata 服務端返回構造體 lttng_viewer_metadata_packet + 指定長度的payload } } 枚舉現存的trace流列表 { 客戶端發送命令 LTTNG_VIEWER_GET_NEXT_INDEX + 構造體 lttng_viewer_get_next_index 服務端返回構造體 lttng_viewer_index 檢查返回的 index.flags, 若是服務端出現了新的流或者元數據, 須要先獲取新的流和元數據才能夠繼續 客戶端發送命令 LTTNG_VIEWER_GET_PACKET + 構造體 lttng_viewer_trace_packet 服務端返回構造體 lttng_viewer_trace_packet + 指定長度的payload 根據metadata packet和trace packet分析事件的內容而後記錄事件 } }
是否是以爲很複雜?
由於協議決定了服務端發給客戶端的數據沒有數據頭, 因此服務端不能主動推送數據到客戶端, 客戶端必須主動的去進行輪詢.
若是你注意到構造體的名稱, 會發現有的構造體後面有request和response而有的沒有, 若是不看上下文只看構造體的名稱很難猜到它們的做用.
正確的作法是全部請求和返回的構造體名稱末尾都添加request和response, 不要去省略這些字母而浪費思考的時間.
爲了發送命令和接收構造體我寫了一些幫助函數, 它們並不複雜, 使用TCP交互的程序都會有相似的代碼:
int sendall(int fd, const void* buf, std::size_t size) { std::size_t pos = 0; while (pos < size) { auto ret = send(fd, reinterpret_cast<const char*>(buf) + pos, size - pos, 0); if (ret <= 0) { return -1; } pos += static_cast<std::size_t>(ret); } return 0; } int recvall(int fd, void* buf, std::size_t size) { std::size_t pos = 0; while (pos < size) { auto ret = recv(fd, reinterpret_cast<char*>(buf) + pos, size - pos, 0); if (ret <= 0) { return -1; } pos += static_cast<std::size_t>(ret); } return 0; } template <class T> int sendcmd(int fd, std::uint32_t type, const T& body) { lttng_viewer_cmd cmd = {}; cmd.data_size = htobe64(sizeof(T)); cmd.cmd = htobe32(type); if (sendall(fd, &cmd, sizeof(cmd)) < 0) { return -1; } if (sendall(fd, &body, sizeof(body)) < 0) { return -1; } return 0; }
初始化鏈接的代碼以下:
lttng_viewer_connect body = {}; body.major = htobe32(2); body.minor = htobe32(9); body.type = htobe32(LTTNG_VIEWER_CLIENT_COMMAND); if (sendcmd(fd, LTTNG_VIEWER_CONNECT, body) < 0) { return -1; } if (recvall(fd, &body, sizeof(body)) < 0) { return -1; } viewer_session_id = be64toh(body.viewer_session_id);
後面的代碼比較枯燥我就省略了, 想看完整代碼的能夠看這裏.
進入循環後會從lttng-relayd
獲取兩種有用的數據:
獲取元數據使用的是LTTNG_VIEWER_GET_METADATA命令, 獲取到的元數據內容以下:
Wu@"Jtf@oe/* CTF 1.8 */ typealias integer { size = 8; align = 8; signed = false; } := uint8_t; typealias integer { size = 16; align = 8; signed = false; } := uint16_t; typealias integer { size = 32; align = 8; signed = false; } := uint32_t; typealias integer { size = 64; align = 8; signed = false; } := uint64_t; typealias integer { size = 64; align = 8; signed = false; } := unsigned long; typealias integer { size = 5; align = 1; signed = false; } := uint5_t; typealias integer { size = 27; align = 1; signed = false; } := uint27_t; trace { major = 1; minor = 8; uuid = "a3df4090-0722-4a74-97a4-81e066406f03"; byte_order = le; packet.header := struct { uint32_t magic; uint8_t uuid[16]; uint32_t stream_id; uint64_t stream_instance_id; }; }; env { hostname = "ubuntu-virtual-machine"; domain = "ust"; tracer_name = "lttng-ust"; tracer_major = 2; tracer_minor = 9; }; clock { name = "monotonic"; uuid = "f397e532-4837-402b-8cc9-700ed92a339d"; description = "Monotonic Clock"; freq = 1000000000; /* Frequency, in Hz */ /* clock value offset from Epoch is: offset * (1/freq) */ offset = 1514336042565610080; }; typealias integer { size = 27; align = 1; signed = false; map = clock.monotonic.value; } := uint27_clock_monotonic_t; typealias integer { size = 32; align = 8; signed = false; map = clock.monotonic.value; } := uint32_clock_monotonic_t; typealias integer { size = 64; align = 8; signed = false; map = clock.monotonic.value; } := uint64_clock_monotonic_t; struct packet_context { uint64_clock_monotonic_t timestamp_begin; uint64_clock_monotonic_t timestamp_end; uint64_t content_size; uint64_t packet_size; uint64_t packet_seq_num; unsigned long events_discarded; uint32_t cpu_id; }; struct event_header_compact { enum : uint5_t { compact = 0 ... 30, extended = 31 } id; variant <id> { struct { uint27_clock_monotonic_t timestamp; } compact; struct { uint32_t id; uint64_clock_monotonic_t timestamp; } extended; } v; } align(8); struct event_header_large { enum : uint16_t { compact = 0 ... 65534, extended = 65535 } id; variant <id> { struct { uint32_clock_monotonic_t timestamp; } compact; struct { uint32_t id; uint64_clock_monotonic_t timestamp; } extended; } v; } align(8); stream { id = 0; event.header := struct event_header_compact; packet.context := struct packet_context; event.context := struct { integer { size = 32; align = 8; signed = 1; encoding = none; base = 10; } _vpid; integer { size = 32; align = 8; signed = 1; encoding = none; base = 10; } _vtid; }; }; event { name = "DotNETRuntime:GCStart_V2"; id = 0; stream_id = 0; loglevel = 13; fields := struct { integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Count; integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Depth; integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Reason; integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Type; integer { size = 16; align = 8; signed = 0; encoding = none; base = 10; } _ClrInstanceID; integer { size = 64; align = 8; signed = 0; encoding = none; base = 10; } _ClientSequenceNumber; }; };
這個元數據的格式是CTF Metadata, 這個格式看上去像json可是並非, 是LTTng的公司本身創造的一個文本格式.
babeltrace中包含了解析這個文本格式的代碼, 可是沒有開聽任何解析它的接口, 也就是若是你想本身解析只能寫一個詞法分析器.
這些格式其實可使用json表示, 體積不會增長多少, 可是這公司硬是發明了一個新的格式增長使用者的負擔.
寫一個詞法分析器須要1天時間和1000行代碼, 這裏我就先跳過了.
接下來獲取跟蹤數據, 使用的是LTTNG_VIEWER_GET_NEXT_INDEX和LTTNG_VIEWER_GET_PACKET命令.
LTTNG_VIEWER_GET_NEXT_INDEX返回了當前流的offset和可獲取的content_size, 這裏的content_size單位是位(bit), 也就是須要除以8才能夠算出能夠獲取多少字節,
關於content_size的單位LTTng中沒有任何文檔和註釋說明它是位, 只有一個測試代碼裏面的某行寫了/ CHAR_BIT
.
使用LTTNG_VIEWER_GET_PACKET命令, 傳入offset和content_size/8能夠獲取跟蹤數據(若是不/8會獲取到多餘的數據或者返回ERR).
實際返回的跟蹤數據以下:
000000: c1 1f fc c1 29 82 6b fe 24 10 4c 6b 97 91 4d c3 ....).k.$.Lk..M. 000010: ed d4 41 8f 00 00 00 00 03 00 00 00 00 00 00 00 ..A............. 000020: 92 91 49 96 08 0a 00 00 07 a0 58 b9 08 0a 00 00 ..I.......X..... 000030: 50 05 00 00 00 00 00 00 00 80 00 00 00 00 00 00 P............... 000040: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 000050: 03 00 00 00 1f 00 00 00 00 92 91 49 96 08 0a 00 ...........I.... 000060: 00 e1 1b 00 00 03 00 00 00 02 00 00 00 01 00 00 ................ 000070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1f ................ 000080: 00 00 00 00 4d ae a7 af 08 0a 00 00 e1 1b 00 00 ....M........... 000090: 04 00 00 00 02 00 00 00 01 00 00 00 00 00 00 00 ................ 0000a0: 00 00 00 00 00 00 00 00 00 00 ..........
跟蹤數據的格式是CTF Stream Packet, 也是一個自定義的二進制格式, 須要配合元數據解析.
babeltrace中一樣沒有開放解析它的接口(有python binding可是沒有解析數據的函數), 也就是須要本身寫二進制數據解析器.
操做LTTng + 和relayd通信 + 元數據詞法分析器 + 跟蹤數據解析器所有加起來預計須要2000行代碼, 而這一切使用ETW只用了100多行代碼.
糟糕的設計, 複雜的使用, 落後的文檔, 各類各樣的自定義協議和數據格式, 不提供SDK把LTTng打形成了一個比ETW更難用的跟蹤系統.
目前在github上LTTng只有100多星而babeltrace只有20多, 也印證了沒有多少人在用它們.
我不清楚爲何CoreCLR要用LTTng, 但欣慰的是CoreCLR 2.1會有新的跟蹤機制EventPipe, 到時候能夠更簡單的實現跨平臺捕獲CoreCLR跟蹤事件.
我目前寫的調用ETW的代碼放在了這裏, 調用LTTng的代碼放在了這裏, 有興趣的能夠去參考.
最差的API(ETW)和更差的API(LTTng)都看過了, 那麼應該如何避免他們的錯誤, 編寫一個好的API呢?
Casey Muratori提到的教訓有:
設計一個API時, 首先要作的是站在調用者的立場, 想一想調用者須要什麼, 如何才能最簡單的達到這個需求.
編寫一個簡單的用例代碼永遠是設計API中必須的一步.
不要過多的去想內部實現, 若是內部實現機制讓API變得複雜, 應該想辦法去抽象它.
由於需求會不斷變化, 設計API的時候應該爲將來的變化預留空間, 保證向後兼容性.
例如ETW中監聽的事件類型使用了位標記, 也就是參數是32位時最多隻能有32種事件, 考慮到將來有更多事件應該把事件類型定義爲連續的數值並提供額外的API啓用事件.
如今有不少接口在設計時會考慮到版本, 例如用v1和v2區分, 這是一個很好的策略.
不要爲了節省代碼去讓一個接口接收或者返回多餘的信息.
在ETW中不少接口都共用了一個大構造體EVENT_TRACE_PROPERTIES
, 調用者很難搞清楚接口使用了構造體裏面的哪些值, 又影響了哪些值.
設計API時應該明確接口的目的, 讓接口接收和返回必要且最少的信息.
對調用者來講, 100行的示例代碼一般比1000行的文檔更有意義.
由於接口的設計者和調用者擁有的知識量一般不對等, 調用者在沒有看到實際的例子以前, 極可能沒法理解設計者編寫的文檔.
這是不少接口都會犯的錯誤, 例如ETW中決定事件附加的信息時, 1表示時間戳, 2表示系統時間, 3表示CPU週期計數.
若是你須要傳遞具備某種意義的數字給接口, 請務必在SDK中爲該數字定義枚舉類型.
我從LTTng中吸取到的教訓有:
99%的調用者沒有看源代碼的興趣或者能力, 不寫文檔沒有人會懂得如何去調用你的接口.
如今有不少自動生成文檔的工具, 用這些工具能夠減小不少的工做量, 可是你仍然應該手動去編寫一個入門的文檔.
創造一個新的協議意味着須要編寫新的代碼去解析它, 並且每一個程序語言都要從新編寫一次.
除非你頗有精力, 能夠爲主流的程序語言都提供一個SDK, 不然不推薦這樣作.
不少項目都提供了REST API, 這是很好的趨勢, 由於幾乎每一個語言都有現成的類庫能夠方便地調用REST API.
定義一個好的二進制協議須要很深的功力, LTTng定義的協議明顯考慮的太少.
推薦的作法是明確區分請求和迴應, 請求和迴應都應該有一個帶有長度的頭, 支持全雙工通訊.
若是你想設計一個二進制協議, 強烈建議參考Cassandra數據庫的協議文檔, 這個協議不管是設計仍是文檔都是一流的水平.
可是若是你沒有對傳輸性能有很苛刻的要求, 建議使用現成的協議加json或者xml.
這裏我沒有寫輕易, 若是你有一個數據結構須要表示成文本, 請使用更通用的格式.
LTTng表示元數據時使用了一個本身創造的DSL, 但裏面的內容用json表示也不會增長多少體積, 也就是說創造一個DSL沒有任何好處.
解析DSL須要本身編寫詞法分析器, 即便是經驗老道的程序員編寫一個也須要很多時間(包含單元測試更多), 若是使用json等通用格式那麼編寫解析的代碼只須要幾分鐘.
雖然這篇文章把LTTng批評了一番, 但這多是目前全世界惟一一篇提到如何經過代碼調用LTTng和接收事件數據的文章. 但願看過這篇文章的設計API時多爲調用者着想, 你偷懶省下幾分鐘每每會致使別人浪費幾天的時間.