Hi,
FlashBurn wrote:I couldn?t imagine a situation where this could lead to a problem.
Ok, imagine you've got some code to add data to a queue like this:
Code: Select all
struc QUEUE_HEADER
.lock: resd 1
.pages: resd 1
.queue_top: resd 1
.data:
endstruc
;Input
; ecx = number of bytes to add to queue
; esi = address of bytes to add to queue
; edi = address of queue header
add_some_data:
lea eax,[edi+QUEUE_HEADER.lock] ;eax = address of re-entrancy lock
call acquire_spinlock ;Lock the queue
mov edx,[edi+QUEUE_HEADER.queue_top] ;edx = offset to top of queue
add edx,ecx ;edx = offset for top of queue after new data is added
test edx,edx ;Is the last page partially used?
jne .l1 ; no
add edx,0x1000 ; yes, increase size by one page
.l1:
shr edx,12 ;edx = total number of pages needed
mov ebx,[edi+QUEUE_HEADER.pages] ;ebx = current number of pages allocated
cmp ebx,edx ;Are there enough pages already?
jae .gotPages ; yes
mov eax,edx ;eax = total number of pages needed
sub eax,[edi+QUEUE_HEADER.pages] ;eax = number of extra pages needed
shl ebx,12 ;ebx = offset to end of allocated space
add ebx,edi ;ebx = address of first un-allocated page
call allocate_pages ;Allocate more pages at the end of the queue
.gotPages:
push edi
cld
add edi,[edi+QUEUE_HEADER.queue_top] ;edi = address to put new data
rep movsb ;Copy the new data to the end of the queue
pop edi ;edi = address of queue header again
lea eax,[edi+QUEUE_HEADER.lock] ;eax = address of re-entrancy lock
call release_spinlock ;Unlock the queue
ret
Now imagine what happens if 2 CPUs do "call add_some_data" at the same time. It goes like this:
- CPU 1: Acquires the "QUEUE_HEADER.lock"
CPU 2: Tries to acquire the "QUEUE_HEADER.lock" but can't
CPU 1: Calls "allocate_pages"
CPU 2: Tries to acquire the "QUEUE_HEADER.lock" but can't
CPU 1: The "allocate_pages" code allocates the pages
CPU 2: Tries to acquire the "QUEUE_HEADER.lock" but can't
CPU 1: The "allocate_pages" sends an "invalidate" IPI
CPU 2: Tries to acquire the "QUEUE_HEADER.lock" but can't
CPU 1: Copies it's data and frees the lock
CPU 2: Acquires the "QUEUE_HEADER.lock"
CPU 1: Returns to caller
CPU 2: Tries to add data to the end of the queue
CPU 2: Generates page fault because of stale TLB information
CPU 2: Page fault handler terminates process due to page fault
CPU 2: Finally recieves the "invalidate" IPI (too late)
In this example, failure is possible when "QUEUE_HEADER.lock" is freed by CPU 1
before the "invalidate" IPI is received by CPU 2.
The only real testing I've done is a "send to self" IPI on a Pentium 4, which took around 50 cycles to arrive (I would assume a "send to a different CPU" IPI would take longer). This creates a "window of failure".
If you're
unlucky, this code would work fine 99.9999% of the time and won't fail until CPU 2 gets too hot and starts running slowly because of thermal throttling, or because something else is flooding the system bus and the IPI takes much longer than normal, and then only when both CPUs are trying to use the "add_some_data" at the same time, and only when the first caller allocates new pages, and possibly only when everything is already in CPU 1's caches.
This is what makes it impossible to find the bug later - because it might only crash once a year on a specific pair of CPUs under a specific load you couldn't recreate the bug and you won't know if something you've done has fixed the bug or not. It'd be an OS that crashes occasionally for unknown reasons...
For this particular example, it's very likely that CPU 1 will take too long to add it's data to the queue before unlocking the lock, and the problem may never occur. This is just one example though, and it's very difficult to predict what unwritten applications might do (or how long an IPI might take on future hardware).
Cheers,
Brendan