多线程会产生多少额外的内存开销

Posted by zhuizhuhaomeng Blog on December 22, 2023

默认情况下,Linux 系统为线程调用栈分配的内存大小为 8M。

  • 我们可以通过 ulimit -s 指令调整调用栈大小。
  • 我们也可以在编程时显示指定调用栈的大小,比如下面通过 pthread_attr_setstacksize 接口设置栈大小。
#include <pthread.h>
#include <stdio.h>

void* thread_function(void* arg) {
    // Thread code here
    return NULL;
}

int main() {
    pthread_t thread;
    pthread_attr_t attr;
    size_t stacksize = 1024 * 1024;  // Set the stack size to 1MB

    // Initialize thread attributes
    pthread_attr_init(&attr);

    // Set the stack size attribute
    pthread_attr_setstacksize(&attr, stacksize);

    // Create the thread with the specified attributes
    pthread_create(&thread, &attr, thread_function, NULL);

    // Destroy the thread attributes object
    pthread_attr_destroy(&attr);

    // Wait for the thread to finish
    pthread_join(thread, NULL);

    return 0;
}

一般情况下,我们只会调整为比 8M 更大,而很少调整为更小。调整为比 8M 更小其实意义不大,因为 Linux 的内存是动态加载的,如果没有使用到是不会实际分配物理内存的。

比如下面的 dockerd 进程的虚拟内存为 3998160KB,而物理内存是 122404KB。

1
2
 ps aux | grep dock  
root        1782  0.1  0.1 3998160 122404 ?      Ssl  08:26   0:03 /usr/bin/dockerd --data-root /home/docker -H fd:// --containerd=/run/containerd/containerd.sock

那么我们如何知道一个进程的栈实际使用了多少内存呢?我们不能简单的将线程的数量乘以 8M。

我们可以使用 pmap 命令来分析一下一个进程的栈实际占用了多少物理内存。

我们以上面的 docker 进程为例子来查看一下进程的内存分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
$ sudo pmap -xp 1782
1782:   /usr/bin/dockerd --data-root /home/docker -H fd:// --containerd=/run/containerd/containerd.sock
Address           Kbytes     RSS   Dirty Mode  Mapping
000000c000000000   22528   22528   22528 rw---   [ anon ]
000000c001600000   14336    8116    8116 rw---   [ anon ]
000000c002400000   28672       0       0 -----   [ anon ]
0000555555554000   21256   10628       0 r---- /usr/bin/dockerd
0000555556a16000   28552   22356       0 r-x-- /usr/bin/dockerd
00005555585f8000    3928    3712       0 r---- /usr/bin/dockerd
00005555589ce000   27028   19640    5708 r---- /usr/bin/dockerd
000055555a433000     740     736     452 rw--- /usr/bin/dockerd
000055555a4ec000     488     212     212 rw---   [ anon ]
00007fff04000000     132       4       4 rw---   [ anon ]
00007fff04021000   65404       0       0 -----   [ anon ]
00007fff08000000     132       4       4 rw---   [ anon ]
00007fff08021000   65404       0       0 -----   [ anon ]
00007fff0c000000     132       4       4 rw---   [ anon ]
00007fff0c021000   65404       0       0 -----   [ anon ]
00007fff10000000     132       4       4 rw---   [ anon ]
00007fff10021000   65404       0       0 -----   [ anon ]
00007fff14000000     132       4       4 rw---   [ anon ]
00007fff14021000   65404       0       0 -----   [ anon ]
00007fff18000000     132       4       4 rw---   [ anon ]
00007fff18021000   65404       0       0 -----   [ anon ]
00007fff1c000000     132       4       4 rw---   [ anon ]
00007fff1c021000   65404       0       0 -----   [ anon ]
00007fff20000000     132       4       4 rw---   [ anon ]
00007fff20021000   65404       0       0 -----   [ anon ]
00007fff24000000     132       4       4 rw---   [ anon ]
00007fff24021000   65404       0       0 -----   [ anon ]
00007fff28000000     132       4       4 rw---   [ anon ]
00007fff28021000   65404       0       0 -----   [ anon ]
00007fff2c000000     132       4       4 rw---   [ anon ]
00007fff2c021000   65404       0       0 -----   [ anon ]
00007fff34000000     132       4       4 rw---   [ anon ]
00007fff34021000   65404       0       0 -----   [ anon ]
00007fff38000000     132       4       4 rw---   [ anon ]
00007fff38021000   65404       0       0 -----   [ anon ]
00007fff3f7ff000       4       0       0 -----   [ anon ]
00007fff3f800000    8192    2048    2048 rw---   [ anon ]
00007fff40000000     132       4       4 rw---   [ anon ]
00007fff40021000   65404       0       0 -----   [ anon ]
00007fff44600000    8192    5380       0 r--s- /home/docker/buildkit/containerdmeta.db
00007fff44ffa000       4       0       0 -----   [ anon ]
00007fff44ffb000    8192       8       8 rw---   [ anon ]
00007fff457fb000       4       0       0 -----   [ anon ]
00007fff457fc000    8192       8       8 rw---   [ anon ]
00007fff45ffc000       4       0       0 -----   [ anon ]
00007fff45ffd000    8192       8       8 rw---   [ anon ]
00007fff467fd000       4       0       0 -----   [ anon ]
00007fff467fe000    8192       8       8 rw---   [ anon ]
00007fff46ffe000       4       0       0 -----   [ anon ]
00007fff46fff000    8192       8       8 rw---   [ anon ]
00007fff477ff000       4       0       0 -----   [ anon ]
00007fff47800000    8192    2048    2048 rw---   [ anon ]
...
00007ffffffde000     132      12      12 rw---   [ stack ]
ffffffffff600000       4       0       0 --x--   [ anon ]
---------------- ------- ------- ------- 
total kB         3998164  128284   64004

实际上这里使用 dockerd 进程是不太好的,因为 docker 是 Go 编写的程序。 Go 大量使用协程而不是普通的操作系统进程,因此 Go 的栈占用的内存应该包含协程的内存更准确。 不过我们这里只关心操作系统线程的内存了。

那么上面的哪些地址是给栈使用的呢?这个其实是很难准确知道的,除了 [ stack ] 这个明显标记为栈的范围。但是天无绝人之路,我们总是可以通过一定的特征来框定我们感兴趣的目标而又不失准确性。

这个就是根据栈大小为 8M 以及在 8M 之后会有一个 4k 的保护区域为特征来查找栈。比如:

1
2
3
4
5
$ sudo pmap -xp 1782
1782:   /usr/bin/dockerd --data-root /home/docker -H fd:// --containerd=/run/containerd/containerd.sock
Address           Kbytes     RSS   Dirty Mode  Mapping
00007fff457fb000       4       0       0 -----   [ anon ]
00007fff457fc000    8192       8       8 rw---   [ anon ]

为什么 8M 之后会有一个 4K 的保护范围呢?我们仔细观察可以知道,这个 4K 的保护范围的属性是 ----。 也就是进程对这段内存既没有写权限,也没有读权限。同时我们可以看到这段内存是没有加载的 (RSS 为 0)。 如果程序读写内存超过 8M 的范围,到达 4K 的地方,那么操作系统就会发送读写异常的信号给进程。

我们看看这个 Go 进程有几个操作系统线程:

1
2
$ sudo ls -l /proc/1782/task | wc -l
45

可以看到有 45 个操作系统线程。我们看看 pmap 的结果有几个 8M 的内存段。

1
2
sudo pmap -xp 1782 | grep 8192 | grep '\[ anon \]' | wc -l
39

可以看到这里的数量是对不上的,因此我们说 Go 程序不是很好的分析例子。

我们看看 Redis 程序的情况:

$ ps aux | grep redis
redis       1322  0.1  0.0 276136 14500 ?        Ssl  08:26   0:03 /usr/bin/redis-server 127.0.0.1:6379
ljl        20460  0.0  0.0 221800  2332 pts/0    S+   09:17   0:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox redis
$ sudo pmap -xp 1322 | grep 8192 | grep '\[ anon \]'
00007fffe75fd000    8192      16      16 rw---   [ anon ]
00007fffe7dfe000    8192      12      12 rw---   [ anon ]
00007fffe85ff000    8192      12      12 rw---   [ anon ]
00007fffe8e00000    8192      12      12 rw---   [ anon ]
00007ffff6c00000    8192    5860    5860 rw---   [ anon ]
$ sudo ls -l /proc/1322/task        
total 0
dr-xr-xr-x 7 redis redis 0 Dec 21 09:18 1322
dr-xr-xr-x 7 redis redis 0 Dec 21 09:18 1458
dr-xr-xr-x 7 redis redis 0 Dec 21 09:18 1459
dr-xr-xr-x 7 redis redis 0 Dec 21 09:18 1460
dr-xr-xr-x 7 redis redis 0 Dec 21 09:18 1462
$ sudo pmap -xp 1322 | grep -B1 8192 
00007fffe75fc000       4       0       0 -----   [ anon ]
00007fffe75fd000    8192      16      16 rw---   [ anon ]
00007fffe7dfd000       4       0       0 -----   [ anon ]
00007fffe7dfe000    8192      12      12 rw---   [ anon ]
00007fffe85fe000       4       0       0 -----   [ anon ]
00007fffe85ff000    8192      12      12 rw---   [ anon ]
00007fffe8dff000       4       0       0 -----   [ anon ]
00007fffe8e00000    8192      12      12 rw---   [ anon ]
00007fffe9600000  218304     400       0 r---- /usr/lib/locale/locale-archive
00007ffff6c00000    8192    5860    5860 rw---   [ anon ]

我们看到这个进程总共有 5 个线程,其中一个是主线程。有 5 个 8192 的地址范围。其中 00007ffff6c00000 前面的地址并不是 4K 大小的保护空间。因此这里可以把这个前面没有 4k 保护地址空间的 8M 地址范围排除。

那么如何才能准确的知道线程的地址呢,答案就是 gdb 和 pmap 结合。 下面的 gdb 命令 给出的 Thread 0x7ffff7c38000 这样的地址在哪个地址范围,那个地址范围对应的肯定是线程栈。并且这个范围不受调用栈大小的影响,我们不必关系调用栈是否 8M 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ sudo gdb -batch -p 1322 -ex "info threads" -ex "quit"
[New LWP 1458]
[New LWP 1459]
[New LWP 1460]
[New LWP 1462]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
0x00007ffff754e84e in epoll_wait (epfd=5, events=0x7ffff7162680, maxevents=10128, timeout=100) at ../sysdeps/unix/sysv/linux/epoll_wait.c:30
30	../sysdeps/unix/sysv/linux/epoll_wait.c: No such file or directory.
  Id   Target Id                                          Frame 
* 1    Thread 0x7ffff7c38000 (LWP 1322) "redis-server"    0x00007ffff754e84e in epoll_wait (epfd=5, events=0x7ffff7162680, maxevents=10128, timeout=100) at ../sysdeps/unix/sysv/linux/epoll_wait.c:30
  2    Thread 0x7fffe95ff640 (LWP 1458) "bio_close_file"  __futex_abstimed_wait_common64 (private=0, cancel=true, abstime=0x0, op=393, expected=0, futex_word=0x5555557cb408) at futex-internal.c:57
  3    Thread 0x7fffe8dfe640 (LWP 1459) "bio_aof_fsync"   __futex_abstimed_wait_common64 (private=0, cancel=true, abstime=0x0, op=393, expected=0, futex_word=0x5555557cb438) at futex-internal.c:57
  4    Thread 0x7fffe85fd640 (LWP 1460) "bio_lazy_free"   __futex_abstimed_wait_common64 (private=0, cancel=true, abstime=0x0, op=393, expected=0, futex_word=0x5555557cb468) at futex-internal.c:57
  5    Thread 0x7fffe7dfc640 (LWP 1462) "jemalloc_bg_thd" __futex_abstimed_wait_common64 (private=0, cancel=true, abstime=0x0, op=393, expected=0, futex_word=0x7ffff72073b4) at futex-internal.c:57
A debugging session is active.

	Inferior 1 [process 1322] will be detached.

Quit anyway? (y or n) [answered Y; input not from terminal]
[Inferior 1 (process 1322) detached]

只要找到了所有的地址范围,把对应的 RSS 加起来就是所有线程栈消耗的物理内存了。