Эта статья предназначена для ознакомления с темами, связанными с потоками и стеками.Статья относительно длинная и в основном будет говорить о следующих темах:
- Существенная разница между процессом и потоком, потоком и разделением памяти
- Потоки Linux и регион Guard
- Принцип реализации защитной области стека потоков Hotspot
- Желтая зона, красная зона, о которых вы, возможно, не слышали
- Принцип реализации Java StackOverflowError
Чтобы прояснить отношения между потоками и стеками, мы должны начать с отношений между процессами и потоками, а затем приступить к первой части.
Часть 1: клише потоков процессов
Во многих статьях в Интернете говорится, что потоки относительно легковесны, а процессы относительно тяжеловесны.Во-первых, давайте посмотрим на разницу и связь между ними.
клонировать системный вызов
С точки зрения верхнего уровня разница между процессом и потоком действительно очень различна, и методы создания и управления ими очень разные. В ядре Linux и процессы, и потоки используют один и тот же системный вызов clone.Далее давайте рассмотрим использование clone. Для удобства выражения мы временно будем использовать process для представления понятий процесса и потока.
Сигнатура функции клонирования выглядит следующим образом.
int clone(int (*fn)(void *),
void *child_stack,
int flags,
void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
Определения параметров следующие:
- Первый параметр fn указывает, что дочерний процесс, сгенерированный clone, будет вызывать функцию, указанную в fn, а параметр задается четвертым параметром arg.
- child_stack представляет пространство стека сгенерированного дочернего процесса.
- Параметр flags очень важен, именно этот параметр определяет, как порожденный дочерний процесс разделяет ресурсы (память, дескрипторы открытых файлов и т. д.) с родительским процессом.
- Остальные параметры, ptid, tls и ctid, связаны с реализацией потока, которая здесь не будет подробно описана.
Далее давайте рассмотрим практический пример, чтобы увидеть влияние флагов на поведение только что созданных «процессов».
Эффекты параметра клонирования
Далее демонстрируется влияние параметра CLONE_VM на поведение родительского и дочернего процессов.Когда параметр командной строки среды выполнения содержит «clone_vm», флаги функции клонирования будут увеличивать CLONE_VM. код показывает, как показано ниже.
static int child_func(void *arg) {
char *buf = (char *)arg;
// 修改 buf 内容
strcpy(buf, "hello from child");
return 0;
}
const int STACK_SIZE = 256 * 1024;
int main(int argc, char **argv) {
char *stack = malloc(STACK_SIZE);
int clone_flags = 0;
// 如果第一个参数是 clone_vm,则给 clone_flags 增加 CLONE_VM 标记
if (argc > 1 && !strcmp(argv[1], "clone_vm")) {
clone_flags |= CLONE_VM;
}
char buf[] = "msg from parent";
if (clone(child_func, stack + STACK_SIZE, clone_flags, buf) == -1) {
exit(1);
}
sleep(1);
printf("in parent, buf:\"%s\"\n", buf);
return 0;
}
Приведенный выше код передает указатель buf родительского процесса дочернему процессу при вызове клона.Когда параметры не принимаются, флаг CLONE_VM не устанавливается, указывая, что виртуальная память не используется совместно, память родительского и дочернего процессов полностью независима, и память дочернего процесса полностью независима.Это копия памяти родительского процесса.Дочерний процесс пишет в память buf только для модификации своей копии памяти, и родительский процесс не может видеть эту модификацию .
Результаты компиляции и запуска следующие.
$ ./clone_test
in parent, buf:"msg from parent"
Видно, что модификация buf дочерним процессом не влияет на родительский процесс.
Давайте посмотрим на результаты, когда во время выполнения добавляется параметр clone_vm:
$ ./clone_test clone_vm
in parent, buf:"hello from child"
Вы можете видеть, что на этот раз дочерний процесс изменил buf, и родительский процесс вступил в силу. Когда флаг CLONE_VM установлен, родительский и дочерний процессы будут совместно использовать память, и изменение буферной памяти дочерним процессом также напрямую повлияет на родительский процесс.
Этот пример должен заложить основу для дальнейшего введения различия между процессом и потоком.Далее, давайте взглянем на существенное различие между процессом и потоком.
обработать и клонировать
Возьмите следующий код в качестве примера.
pid_t gettid() {
return syscall(__NR_gettid);
}
int main() {
pid_t pid;
pid = fork();
if (pid == 0) {
printf("in child, pid: %d, tid:%d\n", getpid(), gettid());
} else {
printf("in parent, pid: %d, tid:%d\n", getpid(), gettid());
}
return 0;
}
Результат выполнения strace выглядит следующим образом:
clone(child_stack=NULL,
flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0x7f75b83b4a10) = 16274
Видно, что единственным флагом, который необходимо отметить среди флагов, используемых процессом создания форка, соответствующего клону, является SIGCHLD.Когда этот флаг установлен, при выходе дочернего процесса система посылает сигнал SIGCHLD родительский процесс, так что родительский процесс может использовать такие функции, как ожидание, чтобы получить его Причина, по которой дочерний процесс завершился.
Можно видеть, что при вызове форка родительский и дочерний процессы не имеют таких ресурсов, как разделяемая память и открытые файлы, что соответствует утверждению, что процесс является единицей инкапсуляции ресурсов, а независимость от ресурсов является отличительная черта процесса. Далее давайте посмотрим на связь между потоками и клонированием.
тред и клон
Вот самый простой код C, чтобы увидеть, что происходит внизу, когда создается поток.Код выглядит следующим образом.
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
void *run(void *args) {
sleep(10000);
}
int main() {
pthread_t t1;
pthread_create(&t1, NULL, run, NULL);
pthread_join(t1, NULL);
return 0;
}
Скомпилируйте приведенный выше код, используя gcc
gcc -o thread_test thread_test.c -lpthread
Затем используйте strace для выполнения thread_test, системный вызов выглядит так.
mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7fefb3986000
clone(child_stack=0x7fefb4185fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7fefb41869d0, tls=0x7fefb4186700, child_tidptr=0x7fefb41869d0) = 12629
mprotect(0x7fefb3986000, 4096, PROT_NONE) = 0
Более важными являются следующие параметры флагов:
отметка | значение |
---|---|
CLONE_VM | общая виртуальная память |
CLONE_FS | Поделитесь свойствами, связанными с файловой системой |
CLONE_FILES | общая открытая таблица файловых дескрипторов |
CLONE_SIGHAND | Совместная обработка сигналов |
CLONE_THREAD | Помещается в группу потоков, к которой принадлежит родительский процесс |
Видно, что суть создания потока заключается в совместном использовании виртуальной памяти процесса, атрибутов файловой системы, списка открытых файлов, обработке сигналов и добавлении сгенерированного потока в группу потоков, к которой принадлежит родительский процесс.
Стоит отметить, что размер памяти, применяемый mmap, составляет не 8M, а 8M + 4K.
8392704 = 8 * 1024 * 1024 + 4096
Почему больше 4К мы разберем во второй части темы и стека.
Часть 2: потоки и стеки
В предыдущем материале мы видели, что просмотр размера стека 8M при создании потока через strace на самом деле выделяет на 4k больше места Это очень интересная проблема, давайте рассмотрим ее подробнее.
Потоки и защитные области
Стек потока — довольно «странный» продукт: с одной стороны, стек потока уникален для потока и хранит такую информацию, как текущее состояние потока, локальные переменные и вызовы функций. С другой стороны, с точки зрения управления ресурсами, стеки всех потоков относятся к ресурсам памяти процесса.Потоки делят ресурсы с родительским процессом, и другие потоки в процессе могут естественным образом изменять память стека любого потока. .
Возьмем в качестве примера следующий код. Этот код создает два потока t1, t2 и соответствующие выполняемые функции: runnable1 и runnable2. Поток t1 копирует адрес массива buf в глобальный указатель p, поток t1 печатает содержимое массива buf каждые 1 с, а поток t2 изменяет содержимое адреса, на который указывает указатель p, каждые 3 с.
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
static char *p;
void *runnable1(void *args) {
char buf[10] = {0};
p = buf;
while (1) {
printf("buffer: %s\n", buf);
sleep(1);
}
}
void *runnable2(void *args) {
int index = 0;
while (1) {
if (p) {
strcpy(p, index++ % 2 == 0 ? "say hello" : "say world");
}
sleep(3);
}
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, runnable1, NULL);
pthread_create(&t2, NULL, runnable2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
Скомпилируйте и запустите приведенный выше код, вывод будет следующим
$ ./thread_stack_test
buf:
buf:
buf:
buf: say hello
buf: say hello
buf: say hello
buf: say world
buf: say world
buf: say world
buf: say hello
buf: say hello
Вы можете видеть, что поток 2 напрямую изменяет содержимое массива в стеке потока 1. Такое поведение полностью допустимо в Linux и не сообщает об ошибках. Если вы можете так свободно обращаться к содержимому других потоков, это очень опасная вещь, например выход за пределы стека, что вызовет путаницу данных в других потоках.
Чтобы уменьшить влияние пересечения стека, операционная система вводит концепцию защиты стека, которая заключается в выделении одной дополнительной страницы (4 КБ) или нескольких страниц памяти для каждого стека потока, которая недоступна для чтения, записи и выполнения. Доступ приведет к ошибке сегментации.
Давайте возьмем практический пример, чтобы увидеть, что стек выходит за пределы, код выглядит следующим образом.
static void *thread_illegal_access(void *arg) {
sleep(1);
char p[1];
int i;
for (i = 0; i < 1024; ++i) {
printf("[%d] access address: %p\n", i, &p[i * 1024]);
p[i * 1024] = 'a';
}
}
static void *thread_nothing(void *arg) {
sleep(1000);
return NULL;
}
int main() {
pthread_t t1;
pthread_t t2;
pthread_create(&t1, NULL, thread_nothing, NULL);
pthread_create(&t2, NULL, thread_illegal_access, NULL);
char str[100];
sprintf(str, "cat /proc/%d/maps > proc_map.txt", getpid());
system(str);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
Скомпилируйте приведенный выше файл c и запустите его с помощью strace, некоторые системные вызовы показаны ниже.
// thread 1
mmap(NULL, 8392704,
PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK,
-1, 0) = 0x7f228d615000
mprotect(0x7f228d615000, 4096, PROT_NONE) = 0
clone(child_stack=0x7f228de14fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f228de159d0, tls=0x7f228de15700, child_tidptr=0x7f228de159d0) = 9696
// thread 2
mmap(NULL, 8392704,
PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f228ce14000
mprotect(0x7f228ce14000, 4096, PROT_NONE) = 0
clone(child_stack=0x7f228d613fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f228d6149d0, tls=0x7f228d614700, child_tidptr=0x7f228d6149d0) = 9697
В linux размер стека потока по умолчанию равен 8M (8388608), но размер блока памяти, выделенного mmap здесь, равен 8392704 (8M+4k), а дополнительные 4k здесь — это размер защиты стека.
После выделения 8M+4k памяти используйте mprotect, чтобы изменить разрешение 4k-адреса только что выделенного блока памяти на PROT_NONE, PROT_NONE означает отказ в доступе, нечитаемость, запись, выполнение. Процесс создания второго потока точно такой же и здесь повторяться не будет.Распределение памяти двух потоков выглядит следующим образом.
$ ./thread_test
[0] access address: 0x7ffff6feef0b
[1] access address: 0x7ffff6fef30b
[2] access address: 0x7ffff6fef70b
[3] access address: 0x7ffff6fefb0b
[4] access address: 0x7ffff6feff0b
[5] access address: 0x7ffff6ff030b
[1] 18133 segmentation fault ./thread_test
Мы видим, что последний доступ вызвал segfault по адресу 0x7ffff6ff030b, который оказался внутри защитной области потока 1. Последний допустимый диапазон все еще находится в допустимой области стека потоков в момент времени t2, как показано ниже.
Как обрабатывается переполнение стека потоков Java
Как упоминалось ранее, потоки Linux реализуют простое предотвращение переполнения стека через область Guard 4k, и ошибка сегментации будет возникать до тех пор, пока область Guard читается и записывается. Вы когда-нибудь задумывались, как Java справляется с переполнением стека?
Когда стек Java-потока переполняется, процесс не завершается, также может быть перехвачено исключение StackOverflowError, и программа может продолжать работу.В качестве примера возьмем следующий код.
public class ThreadStackTest0 {
private static void foo(int i) {
foo(i + 1);
}
public static void main(String[] args) throws IOException {
System.out.println("in main");
new Thread(new Runnable() {
@Override
public void run() {
foo(0);
}
}).start();
System.in.read();
}
}
Скомпилируйте и запустите приведенный выше код, вы можете увидеть
$ javac ThreadStackTest0.java; java -cp . ThreadStackTest0
in main
Exception in thread "Thread-0" java.lang.StackOverflowError
at ThreadStackTest0.foo(ThreadStackTest0.java:8)
at ThreadStackTest0.foo(ThreadStackTest0.java:8)
// ...
Прежде всего, чтобы решить первое сомнение, имеет ли обычный поток Java область 4k Guard собственного потока Linux?
Во-первых, нет. Код для создания потоков в исходном коде Hotspot находится в os_linux.cpp,
bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
// ...
// glibc guard page
pthread_attr_setguardsize(&attr, os::Linux::default_guard_size(thr_type));
// ...
}
Размер защитной области определяется методом os::Linux::default_guard_size. Внутренняя реализация этого метода относительно проста. Он определяет, является ли тип потока обычным потоком Java. Если да, то размер Guard установлен на 0. Если нет, установите его на 4k.
Что такое обычный поток в Java? В дополнение к руководству пользователяnew Thread()
На самом деле для работы JVM требуется много дополнительных вспомогательных потоков, таких как потоки GC, потоки компиляции, потоки-наблюдатели и т. д. Как видно из результатов отладки исходного кода, для Java-потоков размер охранной области установлен равным 0, а для остальных типов потоков установлен по умолчанию 4k.
Не слишком радуйтесь, для нативных потоков Linux нет стандартной области защиты, что не означает, что потоки Java не реализуют свои собственные. Фактически, Hotspot не только взял на себя саму зону Guard, но и реализовал две зоны, одну под названием Yellow Zone и одну под названием Red Zone, как показано ниже.
Размер желтой зоны по умолчанию составляет 8 КБ, доступ к которой можно получить с помощью-XX:StackYellowPages
чтобы указать, что размер красной зоны по умолчанию составляет 4 КБ, что можно указать с помощью-XX:StackRedPages
указать.
Все эти 12 КБ разрешений имеют статус PROT_NONE, то есть недоступны для чтения, записи и выполнения. Чтение и запись в эту область вызовет ошибку сегментации (SIGSEGV). Чтобы самостоятельно обрабатывать исключения переполнения стека, JVM обрабатывает сигнал SIGSEGV.
Вводятся следующие две области:
- Желтая зона: эта область используется для борьбы с восстанавливаемым переполнением стека. Когда в этой области происходит переполнение стека, разрешения области памяти 8 КБ будут изменены на чтение и запись, а затем JVM выдаст исключение StackOverflowError. , StackOverflowError Этот прикладной уровень исключений может быть перехвачен для обработки. После создания и обработки исключения права доступа к области памяти размером 8 КБ будут восстановлены в нечитаемом, недоступном для записи и невыполняемом состоянии.
- Красная зона: эта область используется для борьбы с неустранимым переполнением стека, которое является последней линией защиты для стека потоков. Переполнение стека в этой области будет рассматриваться JVM как фатальная ошибка, процесс завершится и будет сгенерирован файл hs_err_pid.log. Когда стек переполняется в этой области, он сначала изменит разрешения 4k на чтение и запись, чтобы оставить некоторое пространство стека для создания файла hs_err_pid.log.
Посмотреть полный кодos_linux_x86.cpp
,Следующее.
extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig, siginfo_t* info, void* ucVoid, int abort_if_unrecognized) {
// Handle ALL stack overflow variations here
if (sig == SIGSEGV) {
address addr = (address) info->si_addr;
// 检查发生段错误的地址是不是在栈内存的有效范围内 [stack_base-stack_size, stack_base]
if (addr < thread->stack_base() &&
addr >= thread->stack_base() - thread->stack_size()) {
// stack overflow
// 发生段错误的地址处于 yellow 区域
if (thread->in_stack_yellow_zone(addr)) {
// 先把 yellow zone 的 8k 权限改为可读可写,以便调用抛出 STACK_OVERFLOW 异常
thread->disable_stack_yellow_zone();
if (thread->thread_state() == _thread_in_Java) {
// Throw a stack overflow exception. Guard pages will be reenabled
// while unwinding the stack.
stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW);
} else {
// Thread was in the vm or native code. Return and try to finish.
return 1;
}
} else if (thread->in_stack_red_zone(addr)) { // 如果地址在 red zone
// Fatal red zone violation. Disable the guard pages and fall through
// to handle_unexpected_exception way down below.
// 先 disable 掉 red zone,把权限改为可读可写,方便留出 4k 的栈给生成 hs_err_pid.log 文件的代码使用
thread->disable_stack_red_zone();
tty->print_raw_cr("An irrecoverable stack overflow has occurred.");
// This is a likely cause, but hard to verify. Let's just print
// it as a hint.
tty->print_raw_cr("Please check if any of your loaded .so files has "
"enabled executable stack (see man page execstack(8))");
} else {
}
}
}
Каков минимальный размер стека потоков Java?
Это более интересный вопрос, и я не задумывался над ним раньше, я знаю только, что размер стека по умолчанию равен 1M, так что попробуем невзначай:
Видно, что в моей 64-битной системе Centos7 минимальный размер стека для этого значения равен 228 К. Откуда такое значение? Давайте посмотрим на исходный код.
os::Linux::min_stack_allowed = MAX2(os::Linux::min_stack_allowed,
(size_t)(StackYellowPages+StackRedPages+StackShadowPages) * Linux::page_size() +
(2*BytesPerWord COMPILER2_PRESENT(+1)) * Linux::vm_default_page_size());
Функция MAX2 означает получение максимального значения двух входных параметров, значение os::Linux::min_stack_allowed равно 64 КБ, StackYellowPages=2, StackRedPages=1, StackShadowPages=20, значение Linux::page_size() равно 4k, BytesPerWord= 8. Значение Linux::vm_default_page_size() равно 8k.
min_stack_allowed = max(64k, (2 + 1 + 20) * 4k + (2 * 8 + 1) * 8k)
= max(64k, 228k) = 228k
Минимальное значение Xss на Mac — 160k, и правила его расчета немного другие Исходный код выглядит следующим образом:
os::Bsd::min_stack_allowed = MAX2(os::Bsd::min_stack_allowed,
(size_t)(StackYellowPages+StackRedPages+StackShadowPages+
2*BytesPerWord COMPILER2_PRESENT(+1)) * Bsd::page_size());
Процесс расчета таков:
min_stack_allowed = (2 + 1 + 20 + 16 + 1) * 4k = 160k
резюме
В этой статье мы надеемся, что вы сможете получить следующие знания:
- Генерация процессов и потоков, нижний слой генерируется системным вызовом clone
- Большая разница между процессом и потоком заключается в том, что процесс имеет свои собственные независимые ресурсы процесса, в то время как поток является ресурсом общего процесса.
- Размер стека потоков Linux по умолчанию составляет 8 М. В дополнение к памяти стека потоков каждый поток будет иметь дополнительную защитную область размером 4 КБ для предотвращения переполнения стека.
- Размер охранной области обычного потока JHotspot равен 0, но реализацию охранной области он берет на себя
- Hotspot реализует обработку переполнения стека посредством обработки сигналов Yellow-Zone, Red-Zone и пользовательских сигналов SIGSEGV.
- Минимальное значение XSS для JVM зависит от платформы. Конкретный алгоритм см. в приведенном выше содержании.
Если у вас есть какие-либо вопросы, вы можете отсканировать QR-код ниже и подписаться на мою официальную учетную запись, чтобы связаться со мной.