Multithreading - 2 niespodziewane dla mnie wyniki

0

https://godbolt.org/z/sYKoKvo9r

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void* thread_body_1(void* arg) {
  int* shared_var_ptr = (int*)arg;
  (*shared_var_ptr)++;
  printf("%d", *shared_var_ptr);
  return NULL;
}

void* thread_body_2(void* arg) {
  int* shared_var_ptr = (int*)arg;
  *shared_var_ptr += 2;
  printf("%d", *shared_var_ptr);
  return NULL;
}

int main(void) {
  int shared_var = 0;

  pthread_t thread1;
  pthread_t thread2;
   printf("----------------------- %s \t\t\t%d\n", __FUNCTION__, shared_var);

  int result1 = pthread_create(&thread1, NULL,
          thread_body_1, &shared_var);
  int result2 = pthread_create(&thread2, NULL,
          thread_body_2, &shared_var);

  if (result1 || result2) {
    fprintf(stderr, " pthread_create: result1 = %d || result2 = %d\n", result1, result2);
    exit(1);
  }

  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);

  if (result1 || result2) {
    fprintf(stderr, "pthread_join: result1 = %d || result2 = %d\n", result1, result2);
    exit(2);
  }
  printf("\n----------------------- %s \t\t\t%d\n", __FUNCTION__, shared_var);
  return 0;
}

Rozumiem to, jak program daje wyniki:
13
23
12
21

Ale nie rozumiem tego jak program daje wyniki:
31
32
Przypuszczam, ze funkcja printf niekoniecznie odwoluje sie do wartosci aktualnej wskazywanej przez shared_var_ptr (3), tylko korzysta z jakiegos cache - albo nastapilo przelaczenie kontekstu na inny thread przed samym wyswietleniem zmiennej wskazywanej przez shared_var_ptr, kiedy wszystko bylo gotowe do wyswietlenia 1 lub 2, a po powrocie do tego threada nie wykonano sprawdzenia jaka jest wartosc zmiennej wskazywanej przez shared_var_ptr (a zmienila sie na 3), bo zrobiono to juz wczesniej. Hipotetyczna kolejnosc:
main
------------------- przelaczenie kontekstu
thread_body_1
int* shared_var_ptr = (int*)arg; // shared_var = 0
(shared_var_ptr)++; // shared_var = 1
/
przelaczenie kontekstu na inny thread przed samym wyswietleniem zmiennej wskazywanej przez shared_var_ptr, kiedy wszystko bylo gotowe do wyswietlenia 1 /
------------------- przelaczenie kontekstu
thread_body_2
int
shared_var_ptr = (int*)arg; // shared_var = 1
*shared_var_ptr += 2; // shared_var = 3
printf("%d", *shared_var_ptr); // shared_var = 3
return NULL;
------------------- przelaczenie kontekstu
thread_body_1
printf("%d", shared_var_ptr); / po powrocie do tego threada nie wykonano sprawdzenia jaka jest wartosc zmiennej wskazywanej przez shared_var_ptr (a zmienila sie na 3), bo zrobiono to juz wczesniej i wskutek tego teraz wyswietlono 1 */

Zdaje sobie sprawe, ze podana powyzej kolejnosc jest uproszczona bo przelaczenie kontekstu odbywa sie na poziomie instrukcji asemblera, a nawet instrukcja int* shared_var_ptr = (int*)arg; jest w asemblerze zlozona z wielu instrucji, a nie jednej jak w C

5

Zakładasz, że kompilator nie robi optymalizacji.

Kompilator może założyć, że zmienna nie uległa zmianie, bo nie jest to atomic:

  int* shared_var_ptr = (int*)arg;
  (*shared_var_ptr)++;
  printf("%d", *shared_var_ptr); // kompilator pomija dereferencję, bo i tak musiał ją zrobić wcześniej aby inkrementować zmienną. Więc korzysta z pobranej i zainkrementowanej już wartości

Dałeś link do godbolta i jest to świetne narzędzie, które również pokaże ci jak wygląda kod po skompilowaniu. Wygląda on tak:

thread_body_1(void*):                    # @thread_body_1(void*)
        push    rax
        mov     esi, dword ptr [rdi]
        add     esi, 1
        mov     dword ptr [rdi], esi
        mov     edi, offset .L.str
        xor     eax, eax
        call    printf
        xor     eax, eax
        pop     rcx
        ret

Konwencja na linuxie dla x64 jest taka, że pierwsze parametry funkcji wchodzą do rejestrów: rdi, rsi, rdx, rcx, r8 i r9.

Dereferencja:

mov     esi, dword ptr [rdi]       ; do esi kopiujemy to co leży pod adresem rdi

Inkrementacja:

add     esi, 1

Zapisanie wartości do pamięci:

mov     dword ptr [rdi], esi

Wrzucenie jako pierwszy parametr do printf stringa:

mov     edi, offset .L.str

Wyzerowanie eax (w sumie nawet nie wiem po co)

xor     eax, eax

Skok do printf. Czyli: w rdi mamy stringa (format), a w rsi mamy wartość po inkrementacji. Ale nie było nigdzie pomiędzy zawołaniem printf a zapisaniem do pamięci powtórnej dereferencji (skopiowanie z pamięci do rsi).

call    printf

Jeśli wyłączysz optymalizacje kompilatora (flaga -O0) to wtedy kod wygląda tak: https://godbolt.org/z/eYYP9Mvhq

thread_body_1:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        mov     rax, QWORD PTR [rbp-24]
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     eax, DWORD PTR [rax]
        lea     edx, [rax+1]
        mov     rax, QWORD PTR [rbp-8]
        mov     DWORD PTR [rax], edx
        mov     rax, QWORD PTR [rbp-8]
        mov     eax, DWORD PTR [rax]
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     eax, 0
        leave
        ret

I wtedy jest dereferencja przed wrzuceniem parametru do printf.

1

Jak zaznaczył mwl4, funkcja printf nie bierze danych bezpośrednio z adresu pamięci, na który wskazuje shared_var_ptr, tylko przed wywołaniem funkcji printf, wartość z tego adresu jest kopiowana albo do rejestru, albo na stos (w zależności od architektury, na którą kompilujesz i konwencji wywołania funkcji). Kolejność wykonywania kodu, którą podałeś jest możliwa. Samo przetwarzanie danych wewnątrz printf (aż do wywołania syscall write) też trochę trwa, u mnie jest to 1525 instrukcji maszynowych policzonych w gdb, a biblioteka to glibc 2.17 na x86-64.
EDIT:
Wygląda na to, że dane są buforowane przed wywołaniem write (czyli to 1525, to nie jest samo printf), co nie zmienia faktu, że sam printf i tak bierze trochę czasu na przetworzenie danych.

1

A spróbuj do tego shared_var_ptr dodać volatile – efekt "cache'owania" powinien zniknąć, przynajmniej w teorii. Natomiast nadal nie masz gwarancji że wątki będą się odwoływać do zmiennej na przemian, ani nie spowoduje to atomowości operacji.

0

@mwl4:
Po dodaniu flagi kompilacji -O0 nadal program generuje wyniki 31 i 32
Po skompilowaniu gcc -O0 -S race_to_data.c i otrzymaniu race_to_data.s:

	.file	"race_to_data.c"
	.text
	.section	.rodata
.LC0:
	.string	"%d"
	.text
	.globl	thread_body_1
	.type	thread_body_1, @function
thread_body_1:
.LFB6:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$32, %rsp
	movq	%rdi, -24(%rbp)
	movq	-24(%rbp), %rax
	movq	%rax, -8(%rbp)
	movq	-8(%rbp), %rax
	movl	(%rax), %eax
	leal	1(%rax), %edx
	movq	-8(%rbp), %rax
	movl	%edx, (%rax)
	movq	-8(%rbp), %rax
	movl	(%rax), %eax
	movl	%eax, %esi
	leaq	.LC0(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE6:
	.size	thread_body_1, .-thread_body_1
	.globl	thread_body_2
	.type	thread_body_2, @function
thread_body_2:
.LFB7:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$32, %rsp
	movq	%rdi, -24(%rbp)
	movq	-24(%rbp), %rax
	movq	%rax, -8(%rbp)
	movq	-8(%rbp), %rax
	movl	(%rax), %eax
	leal	2(%rax), %edx
	movq	-8(%rbp), %rax
	movl	%edx, (%rax)
	movq	-8(%rbp), %rax
	movl	(%rax), %eax
	movl	%eax, %esi
	leaq	.LC0(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE7:
	.size	thread_body_2, .-thread_body_2
	.section	.rodata
	.align 8
.LC1:
	.string	"----------------------- %s \t\t\t%d\n"
	.align 8
.LC2:
	.string	" pthread_create: result1 = %d || result2 = %d\n"
	.align 8
.LC3:
	.string	"pthread_join: result1 = %d || result2 = %d\n"
	.align 8
.LC4:
	.string	"\n----------------------- %s \t\t\t%d\n"
	.text
	.globl	main
	.type	main, @function
main:
.LFB8:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$32, %rsp
	movl	$0, -12(%rbp)
	movl	-12(%rbp), %eax
	movl	%eax, %edx
	leaq	__FUNCTION__.3386(%rip), %rsi
	leaq	.LC1(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
	leaq	-12(%rbp), %rdx
	leaq	-24(%rbp), %rax
	movq	%rdx, %rcx
	leaq	thread_body_1(%rip), %rdx
	movl	$0, %esi
	movq	%rax, %rdi
	call	pthread_create@PLT
	movl	%eax, -4(%rbp)
	leaq	-12(%rbp), %rdx
	leaq	-32(%rbp), %rax
	movq	%rdx, %rcx
	leaq	thread_body_2(%rip), %rdx
	movl	$0, %esi
	movq	%rax, %rdi
	call	pthread_create@PLT
	movl	%eax, -8(%rbp)
	cmpl	$0, -4(%rbp)
	jne	.L6
	cmpl	$0, -8(%rbp)
	je	.L7
.L6:
	movq	stderr(%rip), %rax
	movl	-8(%rbp), %ecx
	movl	-4(%rbp), %edx
	leaq	.LC2(%rip), %rsi
	movq	%rax, %rdi
	movl	$0, %eax
	call	fprintf@PLT
	movl	$1, %edi
	call	exit@PLT
.L7:
	movq	-24(%rbp), %rax
	movl	$0, %esi
	movq	%rax, %rdi
	call	pthread_join@PLT
	movl	%eax, -4(%rbp)
	movq	-32(%rbp), %rax
	movl	$0, %esi
	movq	%rax, %rdi
	call	pthread_join@PLT
	movl	%eax, -8(%rbp)
	cmpl	$0, -4(%rbp)
	jne	.L8
	cmpl	$0, -8(%rbp)
	je	.L9
.L8:
	movq	stderr(%rip), %rax
	movl	-8(%rbp), %ecx
	movl	-4(%rbp), %edx
	leaq	.LC3(%rip), %rsi
	movq	%rax, %rdi
	movl	$0, %eax
	call	fprintf@PLT
	movl	$2, %edi
	call	exit@PLT
.L9:
	movl	-12(%rbp), %eax
	movl	%eax, %edx
	leaq	__FUNCTION__.3386(%rip), %rsi
	leaq	.LC4(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE8:
	.size	main, .-main
	.section	.rodata
	.type	__FUNCTION__.3386, @object
	.size	__FUNCTION__.3386, 5
__FUNCTION__.3386:
	.string	"main"
	.ident	"GCC: (Debian 8.3.0-6) 8.3.0"
	.section	.note.GNU-stack,"",@progbits


@Azarien :
Po modyfikacji race_to_data.c:

void* thread_body_1(void* arg) {
  volatile int* shared_var_ptr = (volatile int*)arg;
  (*shared_var_ptr)++;
  printf("%d", *shared_var_ptr);
  return NULL;
}

void* thread_body_2(void* arg) {
  volatile int* shared_var_ptr = (volatile int*)arg;
  *shared_var_ptr += 2;
  printf("%d", *shared_var_ptr);
  return NULL;
}

Po dodaniu flagi kompilacji -O0 nadal program generuje wyniki 31 i 32
Po skompilowaniu gcc -O0 -S race_to_data.c i otrzymaniu race_to_data.s uzyskalem identyczny plik tekstowy asemblera jak powyzej czyli jak przed modyfikacja kodu w C (jedynie pod wzgledem binarnym sie roznil).

Przyznam, ze to jest start mojej zabawy z multithreadingiem a asemblera GNU (jak i FASMa i NASMa) znam slabo, ale jestem cierpliwy i nie licze na szybkie efekty. Jakbym liczyl na szybsze to wystartowalbym z Pythonem, ale wg mnie wiecej dowiem sie o komputerach jak bede zajmowac sie C, C++ i asemblerem.

7
teofrast napisał(a):

Przyznam, ze to jest start mojej zabawy z multithreadingiem

Proponuję uczyć się poprawnych technik mt, czyli zmiennych atomowych, locków i innych takich, a nie zaczynać od tworzenia dzikiego race condition i zastanawiania się nad sensem wyniku..

1 użytkowników online, w tym zalogowanych: 0, gości: 1