Thread-Safety Without Ignoring Calls via States & Recurs

Post » Wed Dec 25, 2013 1:07 am

I found myself with a bit of a unique, thread-sensitive problem to solve today, and devised what I feel is a fairly clever and non-obvious method of solving it. I'm posting it here: (1) so other experienced scripters can look over it and see if it has any issues. I have a wealth of scripting experience, but I'm fairly new to Papyrus as a language, so it's possible that some aspects of what I've written will not behave in the way I'm expecting. And (2), so that it may serve as a resource to other modders dealing with a similar issue.

This solution addresses a situation in which multiple threads need to call a function in one script instance at the same time, and none of the calls may be ignored.

One of the earliest state tutorials most new Papyrus users probably see (certainly the first one I saw) is the one about the toggleable lever that uses states to block its activation while it's animating:

State Active    Event OnActivate(ObjectReference akActivatorRef)        GoToState("Inactive")        ;do stuff, wait for animation to finish        GoToState("Active")    endEventendState State Inactive    Event OnActivate(ObjectReference akActivatorRef)        ;do nothing    endEventendState

This obviously won't work in this situation, as we're ignoring function calls by other threads, and we're trying to have all function calls acknowledged. One solution would be to force the threads doing the calling to queue for the Active state. To illustrate, I have to swap out events for a function definition; let's say I have a function foo that does thread-sensitive work. To force the callers to queue for this function in the Active state, I could do something like this:

Scriptname ScriptA extends Quest ;or whatever you needState Active    bool Function foo(int i)        GoToState("Inactive")        ;does some thread-sensitive work        GoToState("Active")        return true  ;by returning true, I tell the caller that its request for this function was acknowledged    endFunctionendState State Inactive    bool Function foo(int i)        return false  ;by returning false, I tell the caller that its request for this function was denied    endFunctionendState

And, in whatever is doing the calling:

Scriptname ScriptB extends ReferenceAlias ;or whatever you need ScriptA Property A Auto;...int j = somevalue  ;an integer of interest to ScriptBwhile !A.foo(j)    wait(0.1)endWhile

But I don't like this solution, because it involves busywait. Instead, I came up with this:

Scriptname ScriptA extends Quest ;or whatever you need int Property i = 0 Auto State Active    Function foo(int i)        GoToState("Inactive")        ;does some thread-sensitive work        GoToState("Active")    endFunction     Event OnBeginState()        int temp = iAccumulator        iAccumulator = 0        if(temp > 0)            foo(temp)        endif    endEventendState State Inactive    Function foo(int i)        iAccumulator += i    endFunctionendState

The first thread to gain access now gets exclusive access to the Active state. If the first thread unlocks the script by calling an external function while doing its thread-sensitive work in foo, then any other threads that get in will deposit the value they need added in iAccumulator. (This of course only works when an accumulator is appropriate - otherwise, you could push values onto an array of parameters. And if each caller is expecting a return value from foo, it of course goes without saying that this won't work.) When the first thread's instance of foo finishes its work and returns to the Active state, the OnBeginState() event fires and evaluates whether any other threads have deposited their values in the accumulator. If so, it runs foo again, making sure that all callers' values are evaluated.

Here's the trick, and the bit that makes it thread-safe: GoToState doesn't return until the OnEndState() event of the old state has finished firing and the OnBeginState() event of the new state has finished firing. So if thread 1's foo reaches GoToState("Active"), and other threads have deposited values in the accumulator, foo and GoToState begin recursing as a pair, with thread 1 still in control. Thread 1's foo reaches GoToState, which won't return until OnBeginState finishes. OnBeginState sees that there is a value in the accumulator and launches another instance of foo. The second instance of foo launches a second instance of GoToState, which waits to return until the next OnBeginState has finished. These two functions recurse like this until they reach a point where no external threads have deposited anything in the accumulator during the latest foo run, at which point OnBeginState doesn't launch another foo instance, which causes the previous GoToState to return, which causes its foo instance to return... all the way down the stack, until every foo and GoToState has been popped off, at which point thread 1 unlocks the script and the script is now waiting in the Active state for another caller to call foo.

Pretty nifty, yes? I just hope it works; it's 2AM here, and I haven't had a chance to test it thoroughly.

User avatar
Soraya Davy
 
Posts: 3377
Joined: Sat Aug 05, 2006 10:53 pm

Post » Wed Dec 25, 2013 5:20 am

Looks cool. One question: Is it safe to assume no other thread gets in between OnBeginState()'s return and the actual state change?

E: Also, I may have missed something obvious, but wouldn't this
State Active    Function foo(int i)        GoToState("Inactive")        ;does some thread-sensitive work        int temp = iAccumulator        iAccumulator = 0        if(temp > 0)            foo(temp)        endif        GoToState("Active")    endFunctionendState
work as well?

E2: With the coolness being acknowledged, I think in most cases it's still better to go with the busy waiting or another different approach.
Worrying about efficient synchronization makes sense in scenarios with high contention. If you have high contention, then the first thing you want to do is trying to avoid it. Your recursion works like a queue that is bounded by some stack size limit. You have no guarantee that this queue does not grow faster than it shrinks (again, in a high contention scenario - if you have like one call every 5 seconds, then of course it won't be an issue, but then busy wait should be fine, too).
If you cannot avoid high contention in your case, the alternative is to consider switching to an explicit producer/consumer model, where a single consumer processes the accumulator value periodically in an OnUpdate loop and N producers just write it. You won't even need states for that and, more importantly, it's scalable.
User avatar
Jesus Lopez
 
Posts: 3508
Joined: Thu Aug 16, 2007 10:16 pm


Return to V - Skyrim