一個(gè)基于 Golang 編寫的日志收集和清洗的應(yīng)用需要支持一些基于 JVM 的算子。 算子依賴了一些庫: Groovy aviatorscript 該應(yīng)用有如下特征: 1、處理數(shù)據(jù)量大 a. 每分鐘處理幾百萬行日志,日志流速幾十 MB/S; b. 每行日志可能需要執(zhí)行多個(gè)計(jì)算任務(wù),計(jì)算任務(wù)個(gè)數(shù)不好估計(jì),幾個(gè)到幾千都有; c. 每個(gè)計(jì)算任務(wù)需要對一行日志進(jìn)行切分/過濾,一般條件<10個(gè); 2、有一定實(shí)時(shí)性要求,某些數(shù)據(jù)必須在特定時(shí)間內(nèi)算完; 3、4C8G 規(guī)格(后來擴(kuò)展為 8C16G ),內(nèi)存比較緊張,隨著業(yè)務(wù)擴(kuò)展,需要緩存較多數(shù)據(jù);
簡言之,對性能要求很高。
有兩種方案:
Go call Java
使用 Java 重寫這個(gè)應(yīng)用
出于時(shí)間緊張和代碼復(fù)用的考慮選擇了 "Go call Java"。
下文介紹了這個(gè)方案和一些優(yōu)化經(jīng)驗(yàn)。
一 Go call Java
根據(jù) Java 進(jìn)程與 Go 進(jìn)程的關(guān)系可以再分為兩種: 方案1:JVM inside: 使用 JNI 在當(dāng)前進(jìn)程創(chuàng)建出一個(gè) JVM,Go 和 JVM 運(yùn)行在同一個(gè)進(jìn)程里,使用 CGO + JNI 通信。
方案2:JVM sidecar: 額外啟動(dòng)一個(gè)進(jìn)程,使用進(jìn)程間通信機(jī)制進(jìn)行通信。 方案1,簡單測試下性能,調(diào)用 noop 方法 180萬 OPS, 其實(shí)也不是很快,不過相比方案2好很多。 這是目前CGO固有的調(diào)用代價(jià)。
由于是noop方法, 因此幾乎不考慮傳遞參數(shù)的代價(jià)。 方案2,比較簡單進(jìn)程間通信方式是 UDS(Unix Domain Socket) based gRPC但實(shí)際測了一下性能不好, 調(diào)用 noop 方法極限5萬的OPS,并且隨著傳輸數(shù)據(jù)變復(fù)雜伴隨大量臨時(shí)對象加劇 GC 壓力。
不選擇方案2還有一些考慮:
高性能的性能通信方式可以選擇共享內(nèi)存,但共享內(nèi)存也不能頻繁申請和釋放,而是要長期復(fù)用;
一旦要長期使用就意味著要在一塊內(nèi)存空間上實(shí)現(xiàn)一個(gè)多進(jìn)程的 malloc&free 算法;
使用共享內(nèi)存也無法避免需要將對象復(fù)制進(jìn)出共享內(nèi)存的開銷;
上述性能是在我的Mac機(jī)器上測出的,但放到其他機(jī)器結(jié)果應(yīng)該也差不多。
出于性能考慮選擇了 JVM inside 方案。
1 JVM inside 原理
JVM inside = CGO + JNI. C 起到一個(gè) Bridge 的作用。 2 CGO 簡介
是 Go 內(nèi)置的調(diào)用 C 的一種手段。詳情見官方文檔。
GO 調(diào)用 C 的另一個(gè)手段是通過 SWIG,它為多種高級語言調(diào)用C/C++提供了較為統(tǒng)一的接口,但就其在Go語言上的實(shí)現(xiàn)也是通過CGO,因此就 Go call C 而言使用 SWIG 不會(huì)獲得更好的性能。詳情見官網(wǎng)。
以下是一個(gè)簡單的例子,Go 調(diào)用 C 的 printf("hello %s\n", "world")。
package main
//// 可以這里配置一些C編譯參數(shù) // #cgo CFLAGS: // #cgo LDFLAGS: /* #include <stdlib.h> #include <stdio.h>
void hello(const char* msg) { printf("hello %s\n", msg); } */ import "C" import "unsafe"
func main() { // 將Golang字符串轉(zhuǎn)成C風(fēng)格字符串, 使用utf8編碼 // 如果字符串本身包含 '\0' 可能會(huì)導(dǎo)致C風(fēng)格字符串提前結(jié)束 cstring := C.CString("world") C.hello(cstring) // 記得free掉, 否則內(nèi)存泄漏 C.free(unsafe.Pointer(cstring)) }
運(yùn)行結(jié)果輸出:
hello world
在出入?yún)⒉粡?fù)雜的情況下,CGO 是很簡單的,但要注意內(nèi)存釋放。
3 JNI 簡介
JNI 可以用于 Java 與 C 之間的互相調(diào)用,在大量涉及硬件和高性能的場景經(jīng)常被用到。JNI 包含的 Java Invocation API 可以在當(dāng)前進(jìn)程創(chuàng)建一個(gè) JVM。
以下只是簡介JNI在本文中的使用,JNI本身的介紹略過。
下面是一個(gè) C 啟動(dòng)并調(diào)用 Java 的String.format("hello %s %s %d", "world", "haha", 2)并獲取結(jié)果的例子。
#include <stdio.h> #include <stdlib.h> #include "jni.h" JavaVM *bootJvm() { JavaVM *jvm; JNIEnv *env;
JavaVMInitArgs jvm_args; JavaVMOption options[4];
// 此處可以定制一些JVM屬性 // 通過這種方式啟動(dòng)的JVM只能通過 -Djava.class.path= 來指定classpath // 并且此處不支持* options[0].optionString = "-Djava.class.path= -Dfoo=bar"; options[1].optionString = "-Xmx1g"; options[2].optionString = "-Xms1g"; options[3].optionString = "-Xmn256m"; jvm_args.options = options; jvm_args.nOptions = sizeof(options) / sizeof(JavaVMOption); jvm_args.version = JNI_VERSION_1_8; // Same as Java version jvm_args.ignoreUnrecognized = JNI_FALSE; // For more error messages.
JavaVMAttachArgs aargs; aargs.version = JNI_VERSION_1_8; aargs.name = "TODO"; aargs.group = NULL;
JNI_CreateJavaVM(&jvm, (void **) &env, &jvm_args); // 此處env對我們已經(jīng)沒用了, 所以detach掉. // 否則默認(rèn)情況下剛create完JVM, 會(huì)自動(dòng)將當(dāng)前線程Attach上去 (*jvm)->DetachCurrentThread(jvm); return jvm; }
int main() { JavaVM *jvm = bootJvm(); JNIEnv *env; if ((*jvm)->AttachCurrentThread(jvm, (void **) &env, NULL) != JNI_OK) { printf("AttachCurrentThread error\n"); exit(1); }
// 以下是 C 調(diào)用Java 執(zhí)行 String.format("hello %s %s %d", "world", "haha", 2) 的例子
jclass String_class = (*env)->FindClass(env, "java/lang/String"); jclass Object_class = (*env)->FindClass(env, "java/lang/Object"); jclass Integer_class = (*env)->FindClass(env, "java/lang/Integer");
jmethodID format_method = (*env)->GetStaticMethodID(env, String_class, "format", "(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); jmethodID Integer_constructor = (*env)->GetMethodID(env, Integer_class, "<init>", "(I)V");
// string里不能包含中文 否則還需要額外的代碼 jstring j_arg0 = (*env)->NewStringUTF(env, "world"); jstring j_arg1 = (*env)->NewStringUTF(env, "haha"); jobject j_arg2 = (*env)->NewObject(env, Integer_class, Integer_constructor, 2); // args = new Object[3] jobjectArray j_args = (*env)->NewObjectArray(env, 3, Object_class, NULL); // args[0] = j_arg0 // args[1] = j_arg1 // args[2] = new Integer(2) (*env)->SetObjectArrayElement(env, j_args, 0, j_arg0); (*env)->SetObjectArrayElement(env, j_args, 1, j_arg1); (*env)->SetObjectArrayElement(env, j_args, 2, j_arg2); (*env)->DeleteLocalRef(env, j_arg0); (*env)->DeleteLocalRef(env, j_arg1); (*env)->DeleteLocalRef(env, j_arg2);
jstring j_format = (*env)->NewStringUTF(env, "hello %s %s %d"); // j_result = String.format("hello %s %s %d", jargs); jobject j_result = (*env)->CallStaticObjectMethod(env, String_class, format_method, j_format, j_args); (*env)->DeleteLocalRef(env, j_format);
// 異常處理 if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); printf("ExceptionCheck\n"); exit(1); }
jint result_length = (*env)->GetStringUTFLength(env, j_result); char *c_result = malloc(result_length + 1); c_result[result_length] = 0; (*env)->GetStringUTFRegion(env, j_result, 0, result_length, c_result); (*env)->DeleteLocalRef(env, j_result);
printf("java result=%s\n", c_result); free(c_result);
(*env)->DeleteLocalRef(env, j_args); if ((*jvm)->DetachCurrentThread(jvm) != JNI_OK) { printf("AttachCurrentThread error\n"); exit(1); }
printf("done\n"); return 0; }
依賴的頭文件和動(dòng)態(tài)鏈接庫可以在JDK目錄找到,比如在我的Mac上是 /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/include/jni.h /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/server/libjvm.dylib
運(yùn)行結(jié)果
java result=hello world haha 2 done
所有 env 關(guān)聯(lián)的 ref,會(huì)在 Detach 之后自動(dòng)工釋放,但我們的最終方案里沒有頻繁 Attach&Detach,所以上述的代碼保留手動(dòng) DeleteLocalRef 的調(diào)用。否則會(huì)引起內(nèi)存泄漏(上面的代碼相當(dāng)于是持有強(qiáng)引用然后置為 null)。
實(shí)際中,為了性能考慮,還需要將各種 class/methodId 緩存住(轉(zhuǎn)成 globalRef),避免每次都 Find。
可以看到,僅僅是一個(gè)簡單的傳參+方法調(diào)用就如此繁雜,更別說遇到復(fù)雜的嵌套結(jié)構(gòu)了。這意味著我們使用 C 來做 Bridge,這一層不宜太復(fù)雜。
實(shí)際實(shí)現(xiàn)的時(shí)候,我們在 Java 側(cè)處理了所有異常,將異常信息包裝成正常的 Response,C 里不用檢查 Java 異常,簡化了 C 的代碼。
關(guān)于Java描述符
使用 JNI 時(shí),各種類名/方法簽名,字段簽名等用的都是描述符名稱,在 Java 字節(jié)碼文件中,類/方法/字段的簽名也都是使用這種格式。 除了通過 JDK 自帶的 javap 命令可以獲取完整簽名外,推薦一個(gè) Jetbrain Intelli IDEA 的插件 jclasslib Bytecode Viewer ,可以方便的在IDE里查看類對應(yīng)的字節(jié)碼信息。
4 實(shí)現(xiàn)
我們目前只需要單向的 Go call Java,并不需要 Java call Go。
代碼比較繁雜,這里就不放了,就是上述2個(gè)簡介的示例代碼的結(jié)合體。
考慮 Go 發(fā)起的一次 Java 調(diào)用,要經(jīng)歷4步驟。
Go 通過 CGO 進(jìn)入 C 環(huán)境 C 通過 JNI 調(diào)用 Java Java 處理并返回?cái)?shù)據(jù)給 C C 返回?cái)?shù)據(jù)給 Go
二 性能優(yōu)化
上述介紹了 Go call Java 的原理實(shí)現(xiàn),至此可以實(shí)現(xiàn)一個(gè)性能很差的版本。針對我們的使用場景分析性能差有幾個(gè)原因:
單次調(diào)用有固定的性能損失,調(diào)用次數(shù)越多損耗越大; 除了基本數(shù)據(jù)模型外的數(shù)據(jù)(主要是日志和計(jì)算規(guī)則)需要經(jīng)歷多次深復(fù)制才能抵達(dá) Java,數(shù)據(jù)量越大/調(diào)用次數(shù)越多損耗越大; 缺少合理的線程模型,導(dǎo)致每次 Java 調(diào)用都需要 Attach&Detach,具有一定開銷;
以下是我們做的一些優(yōu)化,一些優(yōu)化是針對我們場景的,并不一定通用。
由于間隔時(shí)間有點(diǎn)久了, 一些優(yōu)化的量化指標(biāo)已經(jīng)丟失。
1 預(yù)處理
將計(jì)算規(guī)則提前注冊到 Java 并返回一個(gè) id, 后續(xù)使用該 id 引用該計(jì)算規(guī)則, 減少傳輸?shù)臄?shù)據(jù)量。
Java 可以對規(guī)則進(jìn)行預(yù)處理, 可以提高性能:
Groovy 等腳本語言的靜態(tài)化和預(yù)編譯; 正則表達(dá)式預(yù)編譯; 使用字符串池減少重復(fù)的字符串實(shí)例; 提前解析數(shù)據(jù)為特定數(shù)據(jù)結(jié)構(gòu); Groovy優(yōu)化
為了進(jìn)一步提高 Groovy 腳本的執(zhí)行效率有以下優(yōu)化:
預(yù)編譯 Groovy 腳本為 Java class,然后使用反射調(diào)用,而不是使用 eval ; 嘗試靜態(tài)化 Groovy 腳本: 對 Groovy 不是很精通的人往往把它當(dāng) Java 來寫,因此很有可能寫出的腳本可以被靜態(tài)化,利用 Groovy 自帶的 org.codehaus.groovy.transform.sc.StaticCompileTransformation 可以將其靜態(tài)化(不包含Groovy的動(dòng)態(tài)特性),可以提升效率。 自定義 Transformer 刪除無用代碼: 實(shí)際發(fā)現(xiàn)腳本里包含 打印日志/打印堆棧/打印到標(biāo)準(zhǔn)輸出 等無用代碼,使用自定義 Transformer 移除相關(guān)字節(jié)碼。
設(shè)計(jì)的時(shí)候考慮過 Groovy 沙箱,用于防止惡意系統(tǒng)調(diào)用( System.exit(0) )和執(zhí)行時(shí)間太長。出于性能和難度考慮現(xiàn)在沒有啟動(dòng)沙箱功能。
動(dòng)態(tài)沙箱是通過攔截所有方法調(diào)用(以及一些其他行為)實(shí)現(xiàn)的,性能損失太大。
靜態(tài)沙箱是通過靜態(tài)分析,在編譯階段發(fā)現(xiàn)惡意調(diào)用,通過植入檢測代碼,避免方法長時(shí)間不返回,但由于 Groovy 的動(dòng)態(tài)特性,靜態(tài)分析很難分析出 Groovy 的真正行為( 比如方法的返回類型總是 Object,調(diào)用的方法本身是一個(gè)表達(dá)式,只有運(yùn)行時(shí)才知道 ),因此有非常多的辦法可以繞過靜態(tài)分析調(diào)用惡意代碼。
2 批量化
減少 20%~30% CPU使用率。
初期,我們想通過接口加多實(shí)現(xiàn)的方式將代碼里的 Splitter/Filter 等新增一個(gè) Java 實(shí)現(xiàn),然后保持整體流程不變。
比如我們有一個(gè) Filter
type Filter interface { Filter(string) bool }
除了 Go 的實(shí)現(xiàn)外,我們額外提供一個(gè) Java 的實(shí)現(xiàn),它實(shí)現(xiàn)了調(diào)用 Java 的邏輯。
type JavaFilter struct { }
func (f *JavaFilter) Filter(content string) bool { // call java }
但是這個(gè)粒度太細(xì)了,流量高的應(yīng)用每秒要處理80MB數(shù)據(jù),日志切分/字段過濾等需要調(diào)用非常多次類似 Filter 接口的方法。及時(shí)我們使用了 JVM inside 方案,也無法減少單次調(diào)用 CGO 帶來的開銷。
另外,在我們的場景下,Go call Java 時(shí)要進(jìn)行大量參數(shù)轉(zhuǎn)換也會(huì)帶來非常大的性能損失。
就該場景而言, 如果使用 safe 編程,每次調(diào)用必須對 content 字符串做若干次深拷貝才能傳遞到 Java。
優(yōu)化點(diǎn):
將調(diào)用粒度做粗, 避免多次調(diào)用 Java: 將整個(gè)清洗動(dòng)作在 Java 里重新實(shí)現(xiàn)一遍, 并且實(shí)現(xiàn)批量能力,這樣只需要調(diào)用一次 Java 就可以完成一組日志的多次清洗任務(wù)。
3 線程模型
考慮幾個(gè)背景:
CGO 調(diào)用涉及 goroutine 棧擴(kuò)容,如果傳遞了一個(gè)棧上對象的指針(在我們的場景沒有)可能會(huì)改變,導(dǎo)致野指針; 當(dāng) Go 陷入 CGO 調(diào)用超過一段時(shí)間沒有返回時(shí),Go 就會(huì)創(chuàng)建一個(gè)新線程,應(yīng)該是為了防止餓死其他 gouroutine 吧。
這個(gè)可以很簡單的通過 C 里調(diào)用 sleep 來驗(yàn)證;
C 調(diào)用 Java 之前,當(dāng)前線程必須已經(jīng)調(diào)用過 AttachCurrentThread,并且在適當(dāng)?shù)臅r(shí)候DetachCurrentThread。然后才能安全訪問 JVM。頻繁調(diào)用 Attach&Detach 會(huì)有性能開銷;
在 Java 里做的主要是一些 CPU 密集型的操作。
結(jié)合上述背景,對 Go 調(diào)用 Java 做出了如下封裝:實(shí)現(xiàn)一個(gè) worker pool,有n個(gè)worker(n=CPU核數(shù)*2)。里面每個(gè) worker 單獨(dú)跑一個(gè) goroutine,使用 runtime.LockOSThread() 獨(dú)占一個(gè)線程,每個(gè) worker 初始化后, 立即調(diào)用 JNI 的 AttachCurrentThread 綁定當(dāng)前線程到一個(gè) Java 線程上,這樣后續(xù)就不用再調(diào)用了。至此,我們將一個(gè) goroutine 關(guān)聯(lián)到了一個(gè) Java 線程上。此后,Go 需要調(diào)用 Java 時(shí)將請求扔到 worker pool 去競爭執(zhí)行,通過 chan 接收結(jié)果。
由于線程只有固定的幾個(gè),Java 端可以使用大量 ThreadLocal 技巧來優(yōu)化性能。
注意到有一個(gè)特殊的 Control Worker,是用于發(fā)送一些控制命令的,實(shí)踐中發(fā)現(xiàn)當(dāng) Worker Queue 和 n 個(gè) workers 都繁忙的時(shí)候,控制命令無法盡快得到調(diào)用, 導(dǎo)致"根本停不下來"。
控制命令主要是提前將計(jì)算規(guī)則注冊(和注銷)到 Java 環(huán)境,從而避免每次調(diào)用 Java 時(shí)都傳遞一些額外參數(shù)。
關(guān)于 worker 數(shù)量
按理我們是一個(gè) CPU 密集型動(dòng)作,應(yīng)該 worker 數(shù)量與 CPU 相當(dāng)即可,但實(shí)際運(yùn)行過程中會(huì)因?yàn)榕抨?duì),導(dǎo)致某些配置的等待時(shí)間比較長。我們更希望平均情況下每個(gè)配置的處理耗時(shí)增高,但別出現(xiàn)某些配置耗時(shí)超高(毛刺)。于是故意將 worker 數(shù)量增加。
4 Java 使用 ThreadLocal 優(yōu)化
復(fù)用 Decoder/CharBuffer 用于字符串解碼;
復(fù)用計(jì)算過程中一些可復(fù)用的結(jié)構(gòu)體,避免 ArrayList 頻繁擴(kuò)容;
每個(gè) Worker 預(yù)先在 C 里申請一塊堆外內(nèi)存用于存放每次調(diào)用的結(jié)果,避免多次malloc&free。
當(dāng) ThreadLocal.get() + obj.reset() < new Obj() + expand + GC 時(shí),就能利用 ThreadLocal來加速。
obj.reset() 是重置對象的代價(jià)
expand 是類似ArrayList等數(shù)據(jù)結(jié)構(gòu)擴(kuò)容的代價(jià)
GC 是由于對象分配而引入的GC代價(jià)
大家可以使用JMH做一些測試,在我的Mac機(jī)器上:
ThreadLocal.get() 5.847 ± 0.439 ns/op
new java.lang.Object() 4.136 ± 0.084 ns/op
一般情況下,我們的 Obj 是一些復(fù)雜對象,創(chuàng)建的代價(jià)肯定遠(yuǎn)超過 new java.lang.Object() ,像 ArrayList 如果從零開始構(gòu)建那么容易發(fā)生擴(kuò)容不利于性能,另外熱點(diǎn)路徑上創(chuàng)建大量對象也會(huì)增加 GC 壓力。最終將這些代價(jià)均攤一下會(huì)發(fā)現(xiàn)合理使用 ThreadLocal 來復(fù)用對象性能會(huì)超過每次都創(chuàng)建新對象。
Log4j2的"0 GC"就用到了這些技巧。 由于這些Java線程是由JNI在Attach時(shí)創(chuàng)建的,不受我們控制,因此無法定制Thread的實(shí)現(xiàn)類,否則可以使用類似Netty的FastThreadLocal再優(yōu)化一把。
5 unsafe編程
減少 10%+ CPU使用率。
如果嚴(yán)格按照 safe 編程方式,每一步驟都會(huì)遇到一些揪心的性能問題:
Go 調(diào)用 C: 請求體主要由字符串?dāng)?shù)組組成,要拷貝大量字符串,性能損失很大 大量 Go 風(fēng)格的字符串要轉(zhuǎn)成 C 風(fēng)格的字符串,此處有 malloc,調(diào)用完之后記得 free 掉。
Go 風(fēng)格字符串如果包含 '\0',會(huì)導(dǎo)致 C 風(fēng)格字符串提前結(jié)束。
C 調(diào)用 Java: C 風(fēng)格的字符串無法直接傳遞給 Java,需要經(jīng)歷一次解碼,或者作為 byte[] (需要一次拷貝)傳遞給 Java 去解碼(這樣控制力高一些,我們需要考慮 UTF8 GBK 場景)。
Java 處理并返回?cái)?shù)據(jù)給 C: 結(jié)構(gòu)體比較復(fù)雜,C 很難表達(dá),比如二維數(shù)組/多層嵌套結(jié)構(gòu)體/Map 結(jié)構(gòu),轉(zhuǎn)換代碼繁雜易錯(cuò)。
C 返回?cái)?shù)據(jù)給 Go: 此處相當(dāng)于是上述步驟的逆操作,太浪費(fèi)了。
多次實(shí)踐時(shí)候,針對上述4個(gè)步驟分別做了優(yōu)化:
1. Go調(diào)用C: Go 通過 unsafe 拿到字符串底層指針地址和長度傳遞給 C,全程只傳遞指針(轉(zhuǎn)成 int64),避免大量數(shù)據(jù)拷貝。
a. 我們需要保證字符串在堆上分配而非棧上分配才行,Go 里一個(gè)簡單的技巧是保證數(shù)據(jù)直接或間接跨goroutine引用就能保證分配到堆上。還可以參考 reflect.ValueOf() 里調(diào)用的 escape 方法。
b. Go的GC是非移動(dòng)式GC,因此即使GC了對象地址也不會(huì)變化
2. C調(diào)用Java: 這塊沒有優(yōu)化,因?yàn)榻Y(jié)構(gòu)體已經(jīng)很簡單了,老老實(shí)實(shí)寫;
3. Java處理并返回?cái)?shù)據(jù)給C: a. Java 解碼字符串:Java 收到指針之后將指針轉(zhuǎn)成 DirectByteBuffer ,然后利用 CharsetDecoder 解碼出 String。 b. Java返回?cái)?shù)據(jù)給C:
1)考慮到返回的結(jié)構(gòu)體比較復(fù)雜,將其 Protobuf 序列化成 byte[] 然后傳遞回去, 這樣 C 只需要負(fù)責(zé)搬運(yùn)幾個(gè)數(shù)值。
2)此處我們注意到有很多臨時(shí)的 malloc,結(jié)合我們的線程模型,每個(gè)線程使用了一塊 ThreadLocal 的堆外內(nèi)存存放 Protobuf 序列化結(jié)果,使用 writeTo(CodedOutputStream.newInstance(ByteBuffer))可以直接將序列化結(jié)果寫入堆外, 而不用再將 byte[] 拷貝一次。
3)經(jīng)過統(tǒng)計(jì)一般這塊 Response 不會(huì)太大,現(xiàn)在大小是 10MB,超過這個(gè)大小就老老實(shí)實(shí)用 malloc&free了。 4. C返回?cái)?shù)據(jù)給Go:Go 收到 C 返回的指針之后,通過 unsafe 構(gòu)造出 []byte,然后調(diào)用 Protobuf 代碼反序列化。之后,如果該 []byte 不是基于 ThreadLocal 內(nèi)存,那么需要主動(dòng) free 掉它。
Golang中[]byte和string
代碼中的 []byte(xxxStr) 和 string(xxxBytes) 其實(shí)都是深復(fù)制。
type SliceHeader struct { // 底層字節(jié)數(shù)組的地址 Data uintptr // 長度 Len int // 容量 Cap int } type StringHeader struct { // 底層字節(jié)數(shù)組的地址 Data uintptr // 長度 Len int }
Go 中的 []byte 和 string 其實(shí)是上述結(jié)構(gòu)體的值,利用這個(gè)事實(shí)可以做在2個(gè)類型之間以極低的代價(jià)做類型轉(zhuǎn)換而不用做深復(fù)制。這個(gè)技巧在 Go 內(nèi)部也經(jīng)常被用到,比如 string.Builder#String() 。
這個(gè)技巧最好只在方法的局部使用,需要對用到的 []byte 和 string的生命周期有明確的了解。需要確保不會(huì)意外修改 []byte 的內(nèi)容而導(dǎo)致對應(yīng)的字符串發(fā)生變化。
另外,將字面值字符串通過這種方式轉(zhuǎn)成 []byte,然后修改 []byte 會(huì)觸發(fā)一個(gè) panic。
在 Go 向 Java 傳遞參數(shù)的時(shí)候,我們利用了這個(gè)技巧,將 Data(也就是底層的 void*指針地址)轉(zhuǎn)成 int64 傳遞到Java。
Java解碼字符串
Go 傳遞過來指針和長度,本質(zhì)對應(yīng)了一個(gè) []byte,Java 需要將其解碼成字符串。
通過如下 utils 可以將 (address, length) 轉(zhuǎn)成 DirectByteBuffer,然后利用 CharsetDecoder 可以解碼到 CharBuffer 最后在轉(zhuǎn)成 String 。
通過這個(gè)方法,完全避免了 Go string 到 Java String 的多次深拷貝。
這里的 decode 動(dòng)作肯定是省不了的,因?yàn)?Go string 本質(zhì)是 utf8 編碼的 []byte,而 Java String 本質(zhì)是 char[].
public class DirectMemoryUtils { private static final Unsafe unsafe;
private static final Class<?> DIRECT_BYTE_BUFFER_CLASS; private static final long DIRECT_BYTE_BUFFER_ADDRESS_OFFSET; private static final long DIRECT_BYTE_BUFFER_CAPACITY_OFFSET; private static final long DIRECT_BYTE_BUFFER_LIMIT_OFFSET;
static { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); unsafe = (Unsafe) field.get(null); } catch (Exception e) { throw new AssertionError(e); }
try { ByteBuffer directBuffer = ByteBuffer.allocateDirect(0); Class<?> clazz = directBuffer.getClass(); DIRECT_BYTE_BUFFER_ADDRESS_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("address")); DIRECT_BYTE_BUFFER_CAPACITY_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("capacity")); DIRECT_BYTE_BUFFER_LIMIT_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("limit")); DIRECT_BYTE_BUFFER_CLASS = clazz; } catch (NoSuchFieldException e) { throw new RuntimeException(e); } }
public static long allocateMemory(long size) { // 經(jīng)過測試 JNA 的 Native.malloc 吞吐量是 unsafe.allocateMemory 的接近2倍 // return Native.malloc(size); return unsafe.allocateMemory(size); }
public static void freeMemory(long address) { // Native.free(address); unsafe.freeMemory(address); }
/** * @param address 用long表示一個(gè)來自C的指針, 指向一塊內(nèi)存區(qū)域 * @param len 內(nèi)存區(qū)域長度 * @return */ public static ByteBuffer directBufferFor(long address, long len) { if (len > Integer.MAX_VALUE || len < 0L) { throw new IllegalArgumentException("invalid len " + len); } // 以下技巧來自O(shè)HC, 通過unsafe繞過構(gòu)造器直接創(chuàng)建對象, 然后對幾個(gè)內(nèi)部字段進(jìn)行賦值 try { ByteBuffer bb = (ByteBuffer) unsafe.allocateInstance(DIRECT_BYTE_BUFFER_CLASS); unsafe.putLong(bb, DIRECT_BYTE_BUFFER_ADDRESS_OFFSET, address); unsafe.putInt(bb, DIRECT_BYTE_BUFFER_CAPACITY_OFFSET, (int) len); unsafe.putInt(bb, DIRECT_BYTE_BUFFER_LIMIT_OFFSET, (int) len); return bb; } catch (Error e) { throw e; } catch (Throwable t) { throw new RuntimeException(t); } }
public static byte[] readAll(ByteBuffer bb) { byte[] bs = new byte[bb.remaining()]; bb.get(bs); return bs; } }
6 左起右至優(yōu)化
先介紹 "左起右至切分": 使用3個(gè)參數(shù) (String leftDelim, int leftIndex, String rightDelim) 定位一個(gè)子字符,表示從給定的字符串左側(cè)數(shù)找到第 leftIndex 個(gè) leftDelim 后,位置記錄為start,繼續(xù)往右尋找 rightDelim,位置記錄為end.則子字符串 [start+leftDelim.length(), end) 即為所求。
其中l(wèi)eftIndex從0開始計(jì)數(shù)。
例子: 字符串="a,b,c,d" 規(guī)則=("," , 1, ",") 結(jié)果="c"
第1個(gè)","右至","之間的內(nèi)容,計(jì)數(shù)值是從0開始的。
字符串="a=1 b=2 c=3" 規(guī)則=("b=", 0, " ") 結(jié)果="2"
第0個(gè)"b="右至" "之間的內(nèi)容,計(jì)數(shù)值是從0開始的。
在一個(gè)計(jì)算規(guī)則里會(huì)有很多 (leftDelim, leftIndex, rightDelim),但很多情況下 leftDelim 的值是相同的,可以復(fù)用。
優(yōu)化算法:
按 (leftDelim, leftIndex, rightDelim) 排序,假設(shè)排序結(jié)果存在 rules 數(shù)組里;
按該順序獲取子字符串;
處理 rules[i] 時(shí),如果 rules[i].leftDelim == rules[i-1].leftDelim,那么 rules[i] 可以復(fù)用 rules[i-1] 緩存的start,根據(jù)排序規(guī)則知 rules[i].leftIndex>=rules[i-1].leftIndex,因此 rules[i] 可以少掉若干次 indexOf 。
7 動(dòng)態(tài)GC優(yōu)化
基于 Go 版本 1.11.9
上線之后發(fā)現(xiàn)容易 OOM.進(jìn)行了一些排查,有如下結(jié)論。
Go GC 的3個(gè)時(shí)機(jī):
已用的堆內(nèi)存達(dá)到 NextGC 時(shí);
連續(xù) 2min 沒有發(fā)生任何 GC;
用戶手動(dòng)調(diào)用 runtime.GC() 或 debug.FreeOSMemory();
Go 有個(gè)參數(shù)叫 GOGC,默認(rèn)是100。當(dāng)每次GO GC完之后,會(huì)設(shè)置 NextGC = liveSize * (1 + GOGC/100)。
liveSize 是 GC 完之后的堆使用大小,一般由需要常駐內(nèi)存的對象組成。
一般常駐內(nèi)存是區(qū)域穩(wěn)定的,默認(rèn)值 GOGC 會(huì)使得已用內(nèi)存達(dá)到 2 倍常駐內(nèi)存時(shí)才發(fā)生 GC。
但是 Go 的 GC 有如下問題:
根據(jù)公式,NextGC 可能會(huì)超過物理內(nèi)存;
Go 并沒有在內(nèi)存不足時(shí)進(jìn)行 GC 的機(jī)制(而 Java 就可以);
于是,Go 在堆內(nèi)存不足(假設(shè)此時(shí)還沒達(dá)到 NextGC,因此不觸發(fā)GC)時(shí)唯一能做的就是向操作系統(tǒng)申請內(nèi)存,于是很有可能觸發(fā) OOM。
可以很容易構(gòu)造出一個(gè)程序,維持默認(rèn) GOGC = 100,我們保證常駐內(nèi)存>50%的物理內(nèi)存 (此時(shí) NextGC 已經(jīng)超過物理機(jī)內(nèi)存了),然后以極快的速度不停堆上分配(比如一個(gè)for的無限循環(huán)),則這個(gè) Go 程序必定觸發(fā) OOM (而 Java 則不會(huì))。哪怕任何一刻時(shí)刻,其實(shí)我們強(qiáng)引用的對象占據(jù)的內(nèi)存始終沒有超過物理內(nèi)存。
另外,我們現(xiàn)在的內(nèi)存由 Go runtime 和 Java runtime (其實(shí)還有一些臨時(shí)的C空間的內(nèi)存)瓜分,而 Go runtime 顯然是無法感知 Java runtime 占用的內(nèi)存,每個(gè) runtime 都認(rèn)為自己能獨(dú)占整個(gè)物理內(nèi)存。實(shí)際在一臺(tái) 8G 的容器里,分1.5G給Java,Go 其實(shí)可用的 < 6G。
實(shí)現(xiàn)
定義:
低水位 = 0.6 * 總內(nèi)存 高水位 = 0.8 * 總內(nèi)存
抖動(dòng)區(qū)間 = [低水位, 高水位] 盡量讓 常駐活躍內(nèi)存 * GOGC / 100 的值維持在這個(gè)區(qū)間內(nèi), 該區(qū)間大小要根據(jù)經(jīng)驗(yàn)調(diào)整,才能盡量使得 GOGC 大但不至于 OOM。
活躍內(nèi)存=剛 GC 完后的 heapInUse
最小GOGC = 50,無論任何調(diào)整 GOGC 不能低于這個(gè)值
最大GOGC = 500 無論任何調(diào)整 GOGC 不能高于這個(gè)值
當(dāng) NextGC < 低水位時(shí),調(diào)高 GOGC 幅度10;
當(dāng) NextGC > 高水位時(shí),立即觸發(fā)一次 GC(由于是手動(dòng)觸發(fā)的,根據(jù)文檔會(huì)有一些STW),然后公式返回計(jì)算出一個(gè)合理的 GOGC;
其他情況,維持 GOGC 不變;
這樣,如果常駐活躍內(nèi)存很小,那么 GOGC 會(huì)慢慢變大直到收斂某個(gè)值附近。如果常駐活躍內(nèi)存較大,那么 GOGC 會(huì)變小,盡快 GC,此時(shí) GC 代價(jià)會(huì)提升,但總比 OOM 好吧!
這樣實(shí)現(xiàn)之后,機(jī)器占用的物理內(nèi)存水位會(huì)變高,這是符合預(yù)期的,只要不會(huì) OOM, 我們就沒必要過早釋放內(nèi)存給OS(就像Java一樣)。
這臺(tái)機(jī)器在 09:44:39 附近發(fā)現(xiàn) NextGC 過高,于是趕緊進(jìn)行一次 GC,并且調(diào)低 GOGC,否則如果該進(jìn)程短期內(nèi)消耗大量內(nèi)存,很可能就會(huì) OOM。
8 使用緊湊的數(shù)據(jù)結(jié)構(gòu)
由于業(yè)務(wù)變化,我們需要在內(nèi)存里緩存大量對象,約有1千萬個(gè)對象。
內(nèi)部結(jié)構(gòu)可以簡單理解為使用 map 結(jié)構(gòu)來存儲(chǔ)1千萬個(gè) row 對象的指針。
type Row struct { Timestamp int64 StringArray []string DataArray []Data // 此處省略一些其他無用字段, 均已經(jīng)設(shè)為nil }
type Data interface { // 省略一些方法 }
type Float64Data struct { Value float64 }
先不考慮map結(jié)構(gòu)的開銷,有如下估計(jì):
Row數(shù)量 = 1千萬
字符串?dāng)?shù)組平均長度 = 10
字符串平均大小 = 12
Data 數(shù)組平均長度 = 4 估算占用內(nèi)存 = Row 數(shù)量*(int64 大小 + 字符串?dāng)?shù)組內(nèi)存 + Data 數(shù)組內(nèi)存) = 1千萬 * (8+10*12+4*8) = 1525MB。
再算上一些臨時(shí)對象,期望常駐內(nèi)存應(yīng)該比這個(gè)值多一些些,但實(shí)際上發(fā)現(xiàn)剛 GC 完常駐內(nèi)存還有4~6G,很容易OOM。
OOM的原因見上文的 "動(dòng)態(tài)GC優(yōu)化"
進(jìn)行了一些猜測和排查,最終驗(yàn)證了原因是我們的算法沒有考慮語言本身的內(nèi)存代價(jià)以及大量無效字段浪費(fèi)了較多內(nèi)存。
算一筆賬:
指針大小 = 8;
字符串占內(nèi)存 = sizeof(StringHeader) + 字符串長度;
數(shù)組占內(nèi)存 = sizeof(SliceHeader) + 數(shù)組cap * 數(shù)組元素占的內(nèi)存;
另外 Row 上有大量無用字段(均設(shè)置為 nil 或0)也要占內(nèi)存;
我們有1千萬的對象, 每個(gè)對象浪費(fèi)8字節(jié)就浪費(fèi)76MB。
這里忽略字段對齊等帶來的浪費(fèi)。
浪費(fèi)的點(diǎn)在:
數(shù)組 ca p可能比數(shù)組 len 長;
Row 上有大量無用字段, 即使賦值為 nil 也會(huì)占內(nèi)存(指針8字節(jié));
較多指針占了不少內(nèi)存;
最后,我們做了如下優(yōu)化:
確保相關(guān) slice 的 len 和 cap 都是剛剛好;
使用新的 Row 結(jié)構(gòu),去掉所有無用字段;
DataArray 數(shù)組的值使用結(jié)構(gòu)體而非指針;
9 字符串復(fù)用
根據(jù)業(yè)務(wù)特性,很可能產(chǎn)生大量值相同的字符串,但卻是不同實(shí)例。對此在局部利用字段 map[string]string 進(jìn)行字符串復(fù)用,讀寫 map 會(huì)帶來性能損失,但可以有效減少內(nèi)存里重復(fù)的字符串實(shí)例,降低內(nèi)存/GC壓力。 為什么是局部? 因?yàn)槿绻且粋€(gè)全局的 sync.Map 內(nèi)部有鎖, 損耗的代價(jià)會(huì)很大。
通過一個(gè)局部的map,已經(jīng)能顯著降低一個(gè)量級的string重復(fù)了,再繼續(xù)提升效果不明顯。 三 后續(xù)
這個(gè) JVM inside 方案也被用于tair的數(shù)據(jù)采集方案,中心化 Agent 也是 Golang 寫的,但 tair 只提供了 Java SDK,因此也需要 Go call Java 方案。
SDK 里會(huì)發(fā)起阻塞型的 IO 請求,因此 worker 數(shù)量必須增加才能提高并發(fā)度。
此時(shí) worker 不調(diào)用 runtime.LockOSThread() 獨(dú)占一個(gè)線程, 會(huì)由于陷入 CGO 調(diào)用時(shí)間太長導(dǎo)致Go 產(chǎn)生新線程, 輕則會(huì)導(dǎo)致性能下降, 重則導(dǎo)致 OOM。
四 總結(jié)
本文介紹了 Go 調(diào)用 Java 的一種實(shí)現(xiàn)方案,以及結(jié)合具體業(yè)務(wù)場景做的一系列性能優(yōu)化。 在實(shí)踐過程中,根據(jù)Go的特性設(shè)計(jì)合理的線程模型,根據(jù)線程模型使用ThreadLocal進(jìn)行對象復(fù)用,還避免了各種鎖沖突。除了各種常規(guī)優(yōu)化之外,還用了一些unsafe編程進(jìn)行優(yōu)化,unsafe其實(shí)本身并不可怕,只要充分了解其背后的原理,將unsafe在局部發(fā)揮最大功效就能帶來極大的性能優(yōu)化。
五 招聘
螞蟻智能監(jiān)控團(tuán)隊(duì)負(fù)責(zé)解決螞蟻金服域內(nèi)外的基礎(chǔ)設(shè)施和業(yè)務(wù)應(yīng)用的監(jiān)控需求,正在努力建設(shè)一個(gè)支撐百萬級機(jī)器集群、億萬規(guī)模服務(wù)調(diào)用場景下的,覆蓋指標(biāo)、日志、性能和鏈路等監(jiān)控?cái)?shù)據(jù),囊括采集、清洗、計(jì)算、存儲(chǔ)乃至大盤展現(xiàn)、離線分析、告警覆蓋和根因定位等功能,同時(shí)具備智能化 AIOps 能力的一站式、一體化的監(jiān)控產(chǎn)品,并服務(wù)螞蟻主站、國際站、網(wǎng)商技術(shù)風(fēng)險(xiǎn)以及金融科技輸出等眾多業(yè)務(wù)和場景。如果你對這方面有興趣,歡迎加入我們。 (責(zé)任編輯:代碼如詩) |