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

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

前言

在前面文章《使用汇编写一个静态服务器》中我们提到过,当请求到来时,通过系统调用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

具体标志作用可参考 https://man7.org/linux/man-pages/man2/clone.2.html

先看下整体代码

%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。


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

- END -


原文始发于微信公众号(十四个字节):从汇编层看多线程共享内存数据安全问题