本文档分析工作由阿里控股-平台技术-基础设施与稳定性工程团队同学完成。
一、问题发现
从8月底开始,集团的amd turind机器上经常会遇到这种情况,整机所有的业务cpi(cycles per instruction)突然从1以下的正常状态飙升到3甚至是4。
因为指令数不变,而执行每一条指令的周期却增加了三四倍,所以在线容器的cpu利用率则也比正常状态增加了三四倍,这不仅导致在线容器的cpu利用率飙升,对离线的压制也进一步增强,导致所有的业务性能都受损。
此类出问题的机型上,离线任务的部署状态为存在一个vcpu核数等于宿主机核数的袋鼠容器(kata container),提供一个虚拟机环境让odps业务运行。
这个问题出现在大促前,而且是在最新一代的AMD服务器上的,上面部署了很多核心业务,有可能对双11大促的稳定性产生了影响,于是将此问题提到最高优先级排查。
二、现象观察
对此问题,我们先观察到了普遍现象,在线cpi和在线的cpu利用率突增,而运行离线的kata container则由于在线的cpu利用率突增,受到在线压制。
在我们以往的经验中,整机的cpi上涨,一般是由于内存带宽、时延上涨导致的,但当前例子表现的情况却截然相反。
下图可以看到是所有业务pod的cpi都突增了:
下图可以看到,所有核的cpi都突增了:
三、问题发现
在问题机器上采集amd的微架构指标。
- badcase
- good case
使用topdown分析法进行分析,结论如下:
前端取指极为异常,指令L1Imiss极高,Inst Cache大量来自remote(其他CCD),指令dispatch被堵住,所以整个core执行变慢了,所以对L3 cache的访问,以及对内存的访问也因此大幅下降了。
对于该问题,我们初步怀疑为某些业务bug导致的split lock问题,进而引起了bus_lock问题,但是使用perf stat-els_locks.bus_lock来抓bus_lock,并未抓到任何split lock。如果不是split lock,那会不会是rund里跑了什么业务,有极大的代码跳转,导致了L1 icache miss高呢?
本着死马当活马医的心态,我们尝试在出问题的时候,直接kill-SIGSTOP,停下了rund虚拟机,马上机器就恢复了正常。
问题既然已经确定,那肯定是rund里某些业务进程导致的问题。于是我们又尝试在出问题的时候,通过串口登陆rund,对于cpu利用率高的进程一个个地kill-SIGSTOP,果然,kill到了某个进程的时候,机器恢复了正常,这样就确认了导致问题的业务进程。
我们采集了多个问题现场,并且解析出了业务对应的project以及sql表,发现出问题的业务,大致有一下特征:它们使用c++的sql执行引擎,但是在c++中会直接调python函数,也就是odps同学所说的udf,来处理一些字符串。
由此可以猜想,问题大概率是python udf导致的了。
四、问题确认
4.1现场信息采集
top查看出问题的业务,发现有线程cpu利用率100%。
对该线程采集perf,发现时间消耗几乎全在__llll_lock_wait_private->__x86_sys_futex里。
并且,使用pstack,采集问题进程的堆栈,发现问题线程的堆栈如下。
__lll_lock_wait_private的汇编代码如下:
.globl __lll_lock_wait_private .type __lll_lock_wait_private,@function .hidden __lll_lock_wait_private .align 16 __lll_lock_wait_private: cfi_startproc pushq %r10 cfi_adjust_cfa_offset(8) pushq %rdx cfi_adjust_cfa_offset(8) cfi_offset(%r10, -16) cfi_offset(%rdx, -24) xorq %r10, %r10 /* No timeout. */ movl $2, %edx LOAD_PRIVATE_FUTEX_WAIT(%esi) cmpl %edx, %eax /* NB: %edx == 2 */ jne 2f 1: LIBC_PROBE(lll_lock_wait_private, 1, %rdi) movl $SYS_futex, %eax syscall 2: movl %edx, %eax xchgl %eax, (%rdi) /* NB: lock is implied */ testl %eax, %eax jnz 1b popq %rdx cfi_adjust_cfa_offset(-8) cfi_restore(%rdx) popq %r10 cfi_adjust_cfa_offset(-8) cfi_restore(%r10) retq cfi_endproc .size __lll_lock_wait_private,.-__lll_lock_wait_private
将其转为C语言如下,函数首先检查传入的锁值 val 是否为 2,该值表示锁已被其他线程持有且处于“竞争状态”(contended state)。若为 2,则调用 futex 系统调用,使当前线程阻塞等待,直到锁的值发生变化(即锁被释放)。
若初始值不是 2,则跳过第一次等待,直接进入循环尝试获取锁。循环中使用 __sync_val_compare_and_swap(原子比较并交换)尝试将锁值从 0(未锁定)原子地改为 2(竞争状态)。如果失败(说明锁已被其他线程获取),则再次调用 futex 进入等待,直到锁值变为 0,此时再次尝试获取。
void __lll_lock_wait_private(int *lock, int val) { int expected = 2; /* Contended state value */ /* If the current value is not 2 (contended), skip the initial wait */ if (val == expected) { /* Wait on the futex until the lock value changes */ syscall(SYS_futex, lock, /* futex address */ FUTEX_WAIT | FUTEX_PRIVATE_FLAG, /* operation */ expected, /* expected value */ NULL, /* timeout (no timeout) */ NULL, /* uaddr2 (unused) */ 0); /* val3 (unused) */ } /* Try to acquire the lock atomically */ while (__sync_val_compare_and_swap(lock, 0, expected) != 0) { /* Lock is still contended, wait again */ syscall(SYS_futex, lock, /* futex address */ FUTEX_WAIT | FUTEX_PRIVATE_FLAG, /* operation */ expected, /* expected value */ NULL, /* timeout (no timeout) */ NULL, /* uaddr2 (unused) */ 0); /* val3 (unused) */ } /* Lock acquired successfully */ }
对问题进程使用bpftrace抓__x86_sys_futex的参数
bpftrace-e'tracepoint:syscalls:sys_enter_futex/pid==12345/{@cnt[tid,args-
>uaddr,args->op,args->val,args->uaddr2,ustack]=count()}interval:s:1{print(@cnt);clear(@cnt)}'
发现问题进程futex系统调用的uaddr参数,是0xffffffff
这里0xffffffff就是从__lll_lock_wait_private传入futex的参数,几乎可以确定,该问题就是split lock。
在rund虚拟机内,使用:
perf stat -e ls_locks.bus_lock
抓bus_lock事件,果然抓到了,并且根据perf record-els_locks.bus_lock,然后perfscript-Fpid,发现bus_lock正是由于问题线程引起的。
4.2尝试复现
使用如下测试程序进行复现,即随便malloc一个地址分配64字节,然后给这个地址加上15,让他跨缓存行,再把这个地址传入lll_lock_wait_private,便可以复现该问题。
在AMD机器上进行测试,果然,整机所有核的cpi都异常升高了,而在intel的最新机型及skylake等老机型进行测试,却并未复现该问题,仅跑该测试程序的物理核的cpi升高到4左右。
将split_lock测试程序,绑定到cpu100上进行测试,结果如下如下。(其中cpu4和cpu100为一个物理核上的两个逻辑核)
AMD机器
intel机器
/* Extracted __lll_lock_wait_private function from glibc * This is a low-level lock wait function for futex-based locking */ .text .globl lll_lock_wait_private_extracted .type lll_lock_wait_private_extracted,@function .align 16 lll_lock_wait_private_extracted: /* Function prologue - save registers */ pushq %r10 pushq %rdx /* Setup futex parameters */ xorq %r10, %r10 /* No timeout (NULL) */ movl $2, %edx /* Expected value = 2 (contended) */ movl $128, %esi /* futex operation: FUTEX_WAIT | FUTEX_PRIVATE_FLAG = 0 | 128 = 128 */ /* Check if lock is already contended */ cmpl %edx, %eax /* Compare current value with 2 */ jne 2f /* If not 2, try to acquire lock */ 1: /* Wait loop - call futex syscall */ movl $202, %eax /* futex system call number (__NR_futex = 202 on x86_64) */ syscall /* Call kernel */ 2: /* Try to acquire lock atomically */ movl %edx, %eax /* Load value 2 into %eax */ xchg %eax, (%rdi) /* Atomic exchange with lock */ /* Check if we got the lock */ testl %eax, %eax /* Test if previous value was 0 */ jnz 1b /* If not, go back to waiting */ /* Function epilogue - restore registers and return */ popq %rdx popq %r10 retq .size lll_lock_wait_private_extracted,.-lll_lock_wait_private_extracted
#define _GNU_SOURCE #include<stdio.h> #include<stdlib.h> #include<pthread.h> #include<unistd.h> #include<sys/syscall.h> #include<linux/futex.h> #include<errno.h> #include<string.h> #include<time.h> /* Declaration of our extracted assembly function */ externvoidlll_lock_wait_private_extracted(int *lock); intmain(){ printf("Testing extracted __lll_lock_wait_private function\n"); printf("================================================\n"); longlong a=malloc(sizeof(longlong) * 8); int *x = a+15; printf("%lx %lx\n", a, x); *x = 2; lll_lock_wait_private_extracted(x); printf("\n=== All Tests Completed ===\n"); return0; }
4.3什么是splitlock
Intel将跨缓存行(cacheline)的原子操作称为SplitLock。
对于splitlock的了解,可以参考这篇文档,写得非常详细
深入剖析splitlocks,i++可能导致的灾难-火山云[1]
五、问题分析
对于问题进程为什么会进入到锁的流程中并且传入一个很有问题的地址0xffffffff,这个问题让我们很困惑。
于是在出现问题的时候,对问题进程进行gcore,可以生成core文件,获取问题进程的内存现场信息。并且用gdb解析,发现问题线程的堆栈如下:是在python解释器中,调用__libc_free的时候,出现bug,导致挂掉的。
对于该问题,因为业务进程是跑在另外拉起的的mount namespace里,直接对业务进程和core进行gdb,在root mount namespace会因为各种用到的动态库的路径找不到对应的库,解析到错误的堆栈和符号信息,因此,通过nsenter进入业务用到的mount namepsace,并且安装glibc和python的debuginfo后,便可以正确的用gdb对该问题进行分析,得到完整的堆栈如上。
可以看出,这个是在python解释器中申请的内存。
5.1libc代码分析
很容易明白,业务方的代码是通过free函数,走到__libc_free,再走到_int_free的。
在int_free中3940行代码触发的_L_lock_5314,正是这一行,调用mutex_lock(&av->mutex);
libc中对mutex_lock的定义如下,由此可见,libc中的mutex_lock(&av->mutex)等价于__lll_lock_wait_private(&av->mutex,0)
而这里的lock即是0xffffffff,而_inf_free中的第一个参数av指针也是0xffffffff,这二者一定是有什么关系。
#define mutex_lock(m) __libc_lock_lock (*(m)) # ifndef __libc_lock_lock # define __libc_lock_lock(NAME) \ ({ lll_lock (NAME, LLL_PRIVATE); 0; }) # endif #define lll_lock(futex, private) \ (void) \ ({ int ignore1, ignore2, ignore3; \ if (__builtin_constant_p (private) && (private) == LLL_PRIVATE) \ __asm __volatile (__lll_lock_asm_start \ ".subsection 1\n\t" \ ".type _L_lock_%=, @function\n" \ "_L_lock_%=:\n" \ "1:\tlea %2, %%" RDI_LP "\n" \ "2:\tsub $128, %%" RSP_LP "\n" \ "3:\tcallq __lll_lock_wait_private\n" \ "4:\tadd $128, %%" RSP_LP "\n" \ "5:\tjmp 24f\n" \ "6:\t.size _L_lock_%=, 6b-1b\n\t" \ ".previous\n" \ LLL_STUB_UNWIND_INFO_5 \ "24:" \ : "=S" (ignore1), "=&D" (ignore2), "=m" (futex), \ "=a" (ignore3) \ : "0" (1), "m" (futex), "3" (0) \ : "cx", "r11", "cc", "memory"); \ else \ __asm __volatile (__lll_lock_asm_start \ ".subsection 1\n\t" \ ".type _L_lock_%=, @function\n" \ "_L_lock_%=:\n" \ "1:\tlea %2, %%" RDI_LP "\n" \ "2:\tsub $128, %%" RSP_LP "\n" \ "3:\tcallq __lll_lock_wait\n" \ "4:\tadd $128, %%" RSP_LP "\n" \ "5:\tjmp 24f\n" \ "6:\t.size _L_lock_%=, 6b-1b\n\t" \ ".previous\n" \ LLL_STUB_UNWIND_INFO_5 \ "24:" \ : "=S" (ignore1), "=D" (ignore2), "=m" (futex), \ "=a" (ignore3) \ : "1" (1), "m" (futex), "3" (0), "0" (private) \ : "cx", "r11", "cc", "memory"); \ })
查看av的数据结构mstate定义如下,可见mutex即是mstate中的第一个字段,所以理所应当,av->mutex的地址即是av的地址。
typedefstructmalloc_state *mstate; structmalloc_state { /* Serialize access. */ mutex_t mutex; /* Flags (formerly in max_fast). */ int flags; /* Fastbins */ mfastbinptr fastbinsY[NFASTBINS]; /* Base of the topmost chunk -- not otherwise kept in a bin */ mchunkptr top; /* The remainder from the most recent split of a small request */ mchunkptr last_remainder; /* Normal bins packed as described above */ mchunkptr bins[NBINS * 2 - 2]; /* Bitmap of bins */ unsignedint binmap[BINMAPSIZE]; /* Linked list */ structmalloc_state *next; /* Linked list for free arenas. Access to this field is serialized by free_list_lock in arena.c. */ structmalloc_state *next_free; /* Number of threads attached to this arena. 0 if the arena is on the free list. Access to this field is serialized by free_list_lock in arena.c. */ INTERNAL_SIZE_T attached_threads; /* Memory allocated from the system in this arena. */ INTERNAL_SIZE_T system_mem; INTERNAL_SIZE_T max_system_mem; };
问题又来了为什么要向_int_free里传入这么奇怪的一个av地址呢?得在__libc_free中找答案。下面是__libc_free的代码,可以看到__libc_free会把要释放的内存,先判断__free_hook是否为NULL,如果不为NULL,则使用__free_hook(mem)进行释放,如果不为NULL,则使用p=mem2chunk(mem);将要释放的mem转为p,然后再调用ar_ptr=arena_for_chunk(p),最后再调用_int_free(ar_ptr,p,0),这里的ar_ptr即是传入_int_free的av指针。
strong_alias (__libc_free, __free) strong_alias (__libc_free, free) void __libc_free(void* mem) { mstate ar_ptr; mchunkptr p; /* chunk corresponding to mem */ void (*hook) (__malloc_ptr_t, const__malloc_ptr_t) = force_reg (__free_hook); if (__builtin_expect (hook != NULL, 0)) { (*hook)(mem, RETURN_ADDRESS (0)); return; } if (mem == 0) /* free(0) has no effect */ return; p = mem2chunk(mem); if (chunk_is_mmapped(p)) /* release mmapped memory. */ { /* see if the dynamic brk/mmap threshold needs adjusting */ if (!mp_.no_dyn_threshold && p->size > mp_.mmap_threshold && p->size <= DEFAULT_MMAP_THRESHOLD_MAX) { mp_.mmap_threshold = chunksize (p); mp_.trim_threshold = 2 * mp_.mmap_threshold; LIBC_PROBE (memory_mallopt_free_dyn_thresholds, 2, mp_.mmap_threshold, mp_.trim_threshold); } munmap_chunk(p); return; } ar_ptr = arena_for_chunk(p); _int_free(ar_ptr, p, 0); } libc_hidden_def (__libc_free)
我们看看这里的p=mem2chunk(mem)
这里的实现很简单,即是将mem的指针减去0x10,然后强转成了mchunkptr,也就是structmalloc_chunk*。
#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ)) typedefstructmalloc_chunk* mchunkptr; structmalloc_chunk { INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */ INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */ structmalloc_chunk* fd; /* double links -- used only if free. */ structmalloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */ structmalloc_chunk* fd_nextsize;/* double links -- used only if free. */ structmalloc_chunk* bk_nextsize; };
接着再看ar_ptr=arena_for_chunk(p),这里的逻辑即是:先判断ptr->size&4是否不等于0?如果不等于0,则返回heap_for_ptr(ptr)->ar_ptr,如果等于0,则返回main_arena。
#define arena_for_chunk(ptr) \ (chunk_non_main_arena(ptr) ? heap_for_ptr(ptr)->ar_ptr : &main_arena) #define chunk_non_main_arena(p) ((p)->size & NON_MAIN_ARENA) #define NON_MAIN_ARENA 0x4
我们对问题ptr进行解析(也就是_int_free的第二个参数)。
显然,对这个ptr执行arena_for_chunk,会返回heap_for_ptr(ptr)->ar_ptr。
define DEFAULT_MMAP_THRESHOLD_MAX(4 * 1024 * 1024 * sizeof(long)) define HEAP_MAX_SIZE(2 * DEFAULT_MMAP_THRESHOLD_MAX) #define heap_for_ptr(ptr) \ ((heap_info *)((unsigned long)(ptr) & ~(HEAP_MAX_SIZE-1)))
可以算出,HEAP_MAX_SIZE,是0x4000000,所以heap_for_ptr(ptr)就等于ptr&~(0x3ffffff),也就是ptr&0xfffffffffc000000。
这里便真相大白
正是这条路径取到的heap_info中的ar_ptr是0xffffffff,所以才导致最终传入_int_free的av指针是0xffffffff,导致了最后的splitlock问题。
到这里,就有两个怀疑的方向
1. heap_info的内存被踩了,导致ar_ptr指针出现了异常;
2. mem-0x10的地址被踩了,导致,mem2chunk(mem)->size&4=4,所以错误的走到了heap_for_ptr(ptr)->ar_ptr这个分支。
5.2python代码分析
staticvoid list_dealloc(PyListObject *op) { Py_ssize_t i; PyObject_GC_UnTrack(op); Py_TRASHCAN_SAFE_BEGIN(op) if (op->ob_item != NULL) { /* Do it backwards, for Christian Tismer. There's a simple test case where somehow this reduces thrashing when a *very* large list is created and immediately deleted. */ i = Py_SIZE(op); while (--i >= 0) { Py_XDECREF(op->ob_item[i]); //这一行 } PyMem_FREE(op->ob_item); } if (numfree < PyList_MAXFREELIST && PyList_CheckExact(op)) free_list[numfree++] = op; else Py_TYPE(op)->tp_free((PyObject *)op); Py_TRASHCAN_SAFE_END(op) }
通过查看frame3的局部变量,可以知道是op->ob_item[17]释放时出的问题。
查看PyListObject的定义,op->ob_item[17]存储的是一个PyObject指针。
typedefstruct { PyObject_VAR_HEAD /* Vector of pointers to list elements. list[0] is ob_item[0], etc. */ PyObject **ob_item; /* ob_item contains space for 'allocated' elements. The number * currently in use is ob_size. * Invariants: * 0 <= ob_size <= allocated * len(list) == ob_size * ob_item == NULL implies ob_size == allocated == 0 * list.sort() temporarily sets allocated to -1 to detect mutations. * * Items must normally not be NULL, except during construction when * the list is not yet visible outside the function that builds it. */ Py_ssize_t allocated; } PyListObject;
将PyListObject解析,所有的ob_item所存储的指针如下:
对第17号元素进行解析,发现它是一个PyString类型的元素:
而string类型是调用PyString_FromString进行分配的,这个函数更往下则是调到了malloc。也就是说,这段内存是malloc申请的,free释放的,按理说不应该出现什么问题,除非是踩内存对其他ob_item用PyObject进行解析,发现这些ob_item都可以正常解析出正常值。
5.3中场小结
解析heap_info地址前后的内存:
如果是heap_info被踩了,为什么前后展现出如此规律的类似单链表的结构呢?
如果是ob_item-0x10,也就是malloc_chunk被踩了,那么为什么只差了两个字节的ob_item本身没有被踩呢?而且这里malloc_chunk的size和prev_size,显现出一种很奇怪的值:
看上去显然不像是正常malloc分配的内存。
再去解析其他ob_item的malloc_chunk,他们的size和prev_size,数值都非常不正常,或许这些根本就不是glibc分配的内存?
5.4jemalloc
我们再次看业务进程所用到的so库,发现业务进程竟然还引用了jemalloc,也就是说,这个业务在使用中,LD_链接到了jemalloc,所以,c++部分使用的是jemalloc来分配内存。但是python部分,为什么会使用libc的ptmalloc呢?
这里有以下几点猜想:
- c++部分使用jemalloc申请和释放内存,python部分使用ptmalloc来申请释放内存;
- 这点导致问题的可能性很小,对应glibc在线上跑了七八年了,如果有这个问题,那大量在线业务早就引爆了;
- 业务本身就是使用jemalloc申请内存的,但是使用libc_free释放内存;
- 这点确实有可能,申请了的内存,然后用libc_free释放,虽然没有实际释放内存,但是可能业务后续也不再用了,在某些情况下,如果释放的内存-0x10,刚好判定为了非main_arena中申请的,那就会走到错误的分支;
- 业务本来就是用jemalloc申请释放,但是某些异常情况,导致使用了libc_free释放内存。
我们对jemalloc的机制进行调查:
dlopen一个so时,传入参数RTLD_DEEPBIND用于防止符号重定位,确保即使有同名符号存在,也优先从当前lib以及当前lib的下级依赖中查找符号(相关说明[2])。因此使用了RTLD_DEEPBIND之后,odps作业进程中依赖的malloc/free会从依赖jemalloc提供的符号变为依赖glibc提供的符号。
而glibc提供了hook机制,可以让内存分配器通过__malloc_hook/__free_hook中设置的函数指针,来跳转到jemalloc的符号执行,jemalloc中通过JEMALLOC_EXPORTvoid(*__free_hook)(void*ptr)=je_free;来将__free_hook赋值给je_free。
我们查看__free_hook,发现__free_hook应该是已经被jemalloc给劫持了的。
查看5.1中提到的__libc_free的代码,可以确定,如果free_hook设置了的话,是会直接调je_free的,但是这里却走到了libc的_int_free,这明显是有问题的。
那么我们猜想,或许是某种竞争情况下free_hook被修改了,导致这里走到了错误的分支。
学习libc的代码,发现__libc_fork->__malloc_fork_lock_parent中,会在__malloc_initialized>=1的情况下,把__free_hook改为free_atfork。
用gdb查看__libc_malloc_initialized=1,所以确实有可能在fork的时候出现__free_hook被改的情况。
我们对用ebpf抓取,发现业务进程确实会调fork,那问题就可能出现在这里了!
而__malloc_initialized默认值是-1,为什么会变成1呢?
原来,设置为1,是在ptmalloc_init中设置的,这里只要调用了libc的valloc、pvalloc、mallinfo、malloc、mallopt、malloc_info、malloc_stats、malloc_trim,就会调用ptmalloc_init,所以有可能就是业务代码中,调用了这些函数,导致了问题。
5.5问题传导路径猜想
因为dlopen libpython.so传入参数RTLD_DEEPBIND用于防止符号重定位,所以odps作业进程中的依赖的malloc/free会从依赖jemalloc提供的符号变为依赖glibc提供的符号,而在__libc_malloc和__libc_free中通过__malloc_hook和__free_hook跳转到je_malloc和je_free。
但是因为调用了malloc_trim,所以导致了glibc中的全局变量__malloc_initialized被设置为了1,而在某次fork中,__free_hook被改成了glibc中的free_atfork,而在同时,执行python解释器的线程,刚好释放了那段问题内存,那段内存本来是jemalloc申请的,但是却在glibc的free_atfork中释放,最终导致了问题。
六、一些思考
6.1为什么host上没感知到?
很简单,因为宿主机的和虚拟机的pmu上下文是分开的,所以在宿主机中使用perfrecord-els_locks.bus_lock抓不到虚拟机里产生的bus_lock,这个很正常。
而在intel机器上,虚拟机里发生split lock,宿主机上会报如下日志:
#AC:fc_vcpu61/257319took a split_lock trap at address:0x40179d
但是在AMD机器上,因为当前集团环境上的5.10版本内核对于AMD机器上的splitlock检测的部分还没有合入,而支持这个功能则要等到这个patch合入内核才行。
x86/cpu:AddBusLockDetectsupportforAMD[3]
(其实这个patch就是把intel的split lock detect功能抽到了一个公共模块上)
6.2WHYAMD?
其实split lock导致bus lock,影响所有的核,这个是一个预期内的行为。
但是英特尔很早就就意识到了这个问题,所以在微架构层面做了一些技术手段(商业机密),将splitlock的影响仅局限在发生splitlock的物理核上。
而且在intel机器上,在内核命令行中把split_lock_detect:ratelimit设置为500的话,连split lock对单个物理核的影响几乎也消除了(这点目前原因不明,看上去这个命令行参数只是控制探测的频率,并没有控制split lock产生的频率)。
而AMD可能是近几年服务器cpu刚刚有起色,有些坑还没有踩到,所以目前还是直接产生了bus lock。
##6.3如何避免splitlock
而在当前问题中,split lock是因为业务方用了jemalloc作为内存分配器,又在c++程序中调用了python代码,dlopen libpython.so的情况下传入参数RTLD_DEEPBIND使用libc中的__malloc_hook和__free_hook重定位到jemalloc,但又调用了。
malloc_trim导致__free_hook失效。从而在某些时候导致了用glibc的free来释放jemalloc申请的内存。这个bug藏得非常隐蔽,这给我们提供了一个警示,要避免这种类似用法带来的问题。
对于写C/C++等语言的同学,在普遍场景下避免splitlock,则可以参考阿里云的这篇文章:
规避SplitLock性能争抢最佳实践-阿里云[4]
1.确保原子变量对齐到自然边界。
// 推荐:64 字节对齐,避免跨行和伪共享 alignas(64) atomic<uint64_t> counter; // 针对 128 位原子类型,16 字节对齐 alignas(16) atomic<__int128> big_counter;
2.避免将大原子变量放置在结构体中间。
// 不推荐:原子变量可能因前面的成员而发生位移,导致跨行 structBadExample { char a; // 占用 1 字节 atomic<__int128> val; // 可能跨缓存行 }; // 推荐:将对齐要求最高的成员放在最前,并显式声明 structGoodExample { alignas(16) atomic<__int128> val; char a; };
3.使用更小的原子类型组合替代大原子。对于不需要真正128位原子性的场景,可拆分为两个64位原子操作。
structPaddedCounter { alignas(64) atomic<uint64_t> low; alignas(64) atomic<uint64_t> high; };
4.不要使用未对齐指针进行原子操作。
//错误:malloc 不保证 16 字节对齐(尤其老 libc) void* ptr = malloc(sizeof(atomic<__int128>)); atomic<__int128>* p = new(ptr) atomic<__int128>; //正确: 使用 aligned_alloc void* aligned_ptr = aligned_alloc(16, sizeof(atomic<__int128>)); atomic<__int128>* p = new(aligned_ptr) atomic<__int128>;
5.使用static_assert检查对齐。
static_assert(alignof(atomic<__int128>) >= 16, "128-bit atomic must be 16-byte aligned");
6.避免在packed结构体中使用原子类型。
#pragma pack(push, 1) structPacked { uint8_t flag; atomic<uint64_t> counter; // 错误:8字节也可能因紧凑布局跨行 }; #pragma pack(pop)
6.4splitlock检测
在intelemr机器上,可以通过perf stat-e r102c来检测,并且内核日志里也会报告,#AC:fc_vcpu61/257319 took a split_lock trap at address: 0x40179d。
在AMD机器上,通过perf stat-els_locks.bus_lock来检测,而内核的split_lock_detect,则需要等到后续版本合入。
七、后续措施
- odps方面
- 这个问题,后续由odps团队对频繁触发问题的作业,开启了isolation模式,即强制业务进程全局使用tcmalloc,避免了jemalloc和glibc ptmalloc混用导致的问题。
- 经过半个月的灰度过后,该问题确实已基本不再复现,少数的复现也是没有开启isolation模式的作业,到此这个问题基本可以实锤了。
- 后续,odps会通过避免调用malloc_trim等函数,避免__free_hook失效来解决。
- 内核方面
- 对于AMD机器上split lock检测的问题,内核patch在验证后会在下个内核小版本来支持对AMD机器的split lock检测功能。而在支持了该功能之后,我们便可以在根据宿主机内核日志来判定,当前机器是否发生了split lock问题。
- AMD
- 同时AMD的技术同学也承诺,后续会对这个问题,在下一代cpu的微架构层面采取一定措施,避免引发bus_lock问题。
参考链接:
[1]https://developer.volcengine.com/articles/7096405105133502471
[2]https://help.totalview.io/previous_releases/2024.1/HTML/index.html#page/TotalView/totalviewlhug-memory-debugging-requirements.34.15.html
[3]https://lwn.net/ml/all/20240806125442.1603-5-ravi.bangoria@amd.com/
[4]https://help.aliyun.com/zh/ecs/user-guide/best-practices-for-avoiding-split-lock-performance-scramble
来源 | 阿里云开发者公众号
作者 | 红叶