从汇编层看多线程内存数据安全问题

300 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第9天,点击查看活动详情

前言

在前面文章《使用汇编写一个静态服务器》中我们提到过,当请求到来时,通过系统调用fork创建新的子进程去处理他,之所以没创建线程,是因为不会,因为Linux系统调用中没有直接提供创建线程的调用,所以就需要我们自己实现一个pthread_create函数,但因我不研究这方面,但又想体验lock指令的作用,所以不得不学习pthread_create函数的原理去创建新的线程。

简单说pthread_create底层是通过clone调用来完成的,而他的主要用途就是实现线程, 难在理解他们要共享哪些资源,clone调用需要传递要共享的特定资源相对应的标志,标准线程会具有以下标志。

CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO

具体标志作用可参考 man7.org/linux/man-p…

先看下整体代码

%include "/home/HouXinLin/project/nasm/include/io.inc"

bits 32
global CMAIN

%define CLONE_VM	0x00000100
%define CLONE_FS	0x00000200
%define CLONE_FILES	0x00000400
%define CLONE_SIGHAND	0x00000800
%define CLONE_PARENT	0x00008000
%define CLONE_THREAD	0x00010000
%define CLONE_IO	0x80000000


%define MAP_GROWSDOWN	0x0100
%define MAP_ANONYMOUS	0x0020
%define MAP_PRIVATE	0x0002
%define PROT_READ	0x1
%define PROT_WRITE	0x2
%define PROT_EXEC	0x4

%define THREAD_FLAGS \
 CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO

%define STACK_SIZE	(4096 * 1024)

section .data

total:  dd 0
timeval:
    tv_sec  dd 0
    tv_usec dd 0
section .text
CMAIN:
        mov     ebp, esp
        mov     ebx, threadFunction
        call    createThread

        mov     ebx, threadFunction
        call    createThread


waitF:	
        mov     dword [tv_sec], 1
        mov     dword [tv_usec], 0
        mov     eax, 162
        mov     ebx, timeval
        mov     ecx, 0
        int     0x80
        
        
        mov     eax,[total]
        mov     ebx,0
        mov     ecx,10
        mov     edx,0
        call    printTotal
        
        call    exit


threadFunction:
        mov     ecx,10000
_nextAdd:
        lock    inc dword[total]
        loop    _nextAdd
	call   exit
createThread:
	push   ebx
	call   createStack
	lea    ecx, [eax + STACK_SIZE - 8]
	pop    dword [ecx]
	mov    ebx, THREAD_FLAGS
	mov    eax, 120
	int    0x80
	ret

createStack:

	mov    ebx, 0
	mov    ecx, STACK_SIZE
	mov    edx, PROT_WRITE | PROT_READ
	mov    esi, MAP_ANONYMOUS | MAP_PRIVATE | MAP_GROWSDOWN
	mov    eax, 192
	int    0x80
	ret
printTotal:
        div     ecx
        push    edx
        inc     ebx     ;;存取结果是几位数
        cmp     eax,0   ;;商是否为0
        jz      printResult     ;;是0退出
        mov     edx,0
        mov     ecx,10
        jmp     printTotal
printResult: ;;打印结果
        nop    
        cmp     ebx,0
        jz      exit
        mov     eax,[esp]
        call    printNumber
        pop     eax
        dec     ebx
        jmp     printResult
printNumber:
        push    ebx
        add     eax,48
        push    eax
        mov     edx, 1
        mov     ecx,esp
        mov     ebx, 1  
        mov     eax, 4
        int     80h   
        pop     eax
        pop     ebx
        ret
sum:
        add     ebx,4   ;;栈中偏移地址
        add     eax,dword[esp+ebx]  ;;累加
        loop     sum        ;;继续累加
        ret
exit:
        mov     ebx,0
        mov     eax,1
        int     80h    

先不说创建线程的部分,主要看今天的问题,原因借用x86从实模式到保护模式中一句话,讲不清楚就不要讲

在测试中,先创建了两个线程,分别对内存中total变量进行自增1,每个线程增加10000次,最终得出应该是20000,而实际情况都知道,在少数情况下才会得到正确答案,但是当我们加上lock指令前缀的时候,就都变得正常。

lock    inc dword[total]

因为inc指令并不是原子的,可以分为三步,内核先读取存储在内存位置的值,并计算出它的新值,然后将新值存储回去,但是在读取和存储之间存在延迟,其他线程在这个时候操作就可能会影响这个值,而当加入lock指令时,在多处理器环境中可以确保处理器独占使用这个共享内存。

而lock只支持add、adc、and、btc、btr、bts、cmpxchg、cmpxch8b、dec、inc、neg、not、or、sbb、sub、xor、xadd 和xchg。

java虚拟机中也会使用这几个指令,比如cas,他会用到lock+cmpxchg去实现。

mov eax,10
mov ebx,1000
cmpxchg [p],ebx

cmpxchg是比较并交换,当目标操作数和eax寄存器相等,那么就把源操作数加载到目标操作数中,如果不相等,就把目标操作数加载到eax中,比如在这里,当p变量和eax相等时,可以理解为还没有别人修改过内存,就把ebx(原操作数)加载到p变量中,如果在执行这句话前,有线程把p修改了,那么就把p变量加载到eax寄存器中,不会修改内存。

那如何判断结果到底加载到内存没?就是通过标志寄存器,当目标操作数和eax寄存器相等,指令执行后,最终zf是1。

但并发编程水太深,把握不住,我所知道的就这些。