Understanding the async behavior
Handling async behavior is tricky. I was caught into same gotcha when I first started dealing with async code few years back. It's useful behavior in most of the programming applications, especially when we're dealing with heavy or network-based operation. So you cannot really escape from it, so why not learn it better?
Let's look at the simple example how async operation can be confused with the sync behavior which can cause bugs to creep in the application,
class AsyncHandling {
var resultValue: Int?
func loadResultValueWithAsyncOperation() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.resultValue = 100
}
}
func getResultValue() -> Int? {
loadResultValueWithAsyncOperation()
return resultValue
}
func mainFunction() {
let val = getResultValue()
}
}
In the above example we have a class called AsyncHandling
responsible for executing async operation.
If you look inside the class, we have a method named getResultValue
which returns the value of resultValue
. getResultValue
eventually calls loadResultValueWithAsyncOperation
which loads the value asynchronously and getResultValue
immediately returns the resultValue
. For someone who hasn't handled async behavior before this example would look fine.
Do you mean there is a bug in the example above?
Yes. Let's see how!
First off, the call is made to getResultValue
who is responsible for returning correct resultValue
. Inside getResultValue
we then call loadResultValueWithAsyncOperation
and immediately return the resultValue
. Now, loadResultValueWithAsyncOperation
is an async operation, so it's going to take some time before self.resultValue = 100
will be executed. Before that happens, we're immediately returning the resultValue
which hadn't had the change to set up.
So when we call getResultValue
for first time, the resultValue
will be nil
because it hasn't been set at that point. (Although will be set 2 seconds after that in this example, or after indefinite delay in case this is a call to make a network request)
Can you prove it?
Yes, of course. Let's start debugging the behavior by putting print
statements and printing the resultValue
at different levels of execution. Please note that mainFunction
is the one which gets called first in the sequence.
class AsyncHandling {
var resultValue: Int?
func loadResultValueWithAsyncOperation() {
print("Inside loadResultValueWithAsyncOperation")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.resultValue = 100
print("Result value after it has been set up \(self.resultValue)")
}
}
func getResultValue() -> Int? {
print("Inside getResultValue")
loadResultValueWithAsyncOperation()
print("After calling loadResultValueWithAsyncOperation")
print("result value at the time of returning \(resultValue)")
return resultValue
}
func mainFunction() {
let val = getResultValue()
}
}
If you run it again, it will print following on the console,
Inside getResultValue
Inside loadResultValueWithAsyncOperation
After calling loadResultValueWithAsyncOperation
result value at the time of returning nil
Result value after it has been set up Optional(100)
Looking at the log, loadResultValueWithAsyncOperation
is called and it returns immediately. By the time we're ready to return the resultValue
, it's still nil
. However, async call finishes soon and then we get another log from loadResultValueWithAsyncOperation
which gives correct value of resultType
. However, this is of no use since we already returned incorrect resultValue
from inside of getResultValue
.
Ok, so how do we solve this problem?
If you are a professional developer, there is not really workaround to avoid async calls at all. So why not write the code which will handle the async behavior causing irrational behavior? The solution can be provided with the completion closure.
Unlike previous example where we immediately return value, completion closure waits until value is set by the asynchronous call and the notifies the caller.
class AsyncHandling {
var resultValue: Int?
func loadResultValueWithAsyncOperation(completion: @escaping (Int?) -> Void) {
print("Inside loadResultValueWithAsyncOperation")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.resultValue = 100
print("Result value after it has been set up \(self.resultValue)")
completion(self.resultValue)
}
func getResultValue(completion: @escaping (Int?) -> Void) {
print("Inside getResultValue")
loadResultValueWithAsyncOperation(completion: completion)
}
func mainFunction() {
getResultValue { value in
print("Final result value \(value)")
}
}
}
And this is the console log produced by the refactored codebase
Inside getResultValue
Inside loadResultValueWithAsyncOperation
Result value after it has been set up Optional(100)
Final result value Optional(100)
Instead of immediately returning after making async call, we are making use of completion block. Completion blocks are useful that they let caller know when async operation has completed along with the updated value (In this case resultValue
)So this is how we refactored code to handle async calls and avoided returning incorrect result value.
To summarize,
- Avoid global state. Wherever applicable pass the value rather than setting up the global instance in one place and then accessing it from somewhere else
- If your method has a synchronous behavior, it's ok to set and return the value directly
- If your method has an asynchronous behavior, please consider using the completion closure as a mechanism to give callback to caller once async operation completed after indefinite amount of time
- Completion closure need not always has to take parameters. Sometimes caller just needs to know when operation is done. In other cases it might need values computed from async operation (As we saw in the above example)
Hope this post was useful to clear out (at least some) confusion about async behavior. So next time you're working on the method call, please verify if you're dealing with synchronous or asynchronous methods and design your code accordingly.