> Take a simple node.js program or a Rust program that has a single-threaded tokio executor, or a C# WinForm program that reuses the UI thread for running Tasks, and everything I wrote applies. A single Account value used to be fine to be shared between coroutines because only one invocation of ChargeAccount would be active at a time, but the "transparent async" breaks that assumption.
That's not true at all. Here is a simple NodeJS implementation that is completely unsafe despite having non-transparent async:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function LoggerWrite(s) {
await delay(Math.random() * 100);
console.log(s);
}
async function ChargeAccount(account, cents) {
let balance = account.balance;
let newBalance = balance - cents;
await LoggerWrite("changing account from "+ balance + " to " + newBalance);
account.balance = newBalance;
}
async function Main() {
let account = {balance: 0};
let t1 = ChargeAccount(account, 1);
let t2 = ChargeAccount(account, 20);
let t3 = ChargeAccount(account, 18);
let t4 = ChargeAccount(account, 12);
await Promise.all([t1, t2, t3, t4]);
console.log(account);
}
Main()
//will randomly end up with -1 or -12 or -18 or -20, instead of the expected -51
And here is a Go program that does the right thing even with transparent async:
func LoggerWrite(s string) {
<-time.After(100) //this means "block for 100 ms"
fmt.Println(s)
}
func ChargeAccount(account *account, cents int) {
balance := account.balance
newBalance := balance - cents
LoggerWrite(fmt.Sprintf("changing account from %d to %d", balance, newBalance))
account.balance = newBalance
}
func main() {
account := account{balance: 0}
ChargeAccount(&account, 1)
ChargeAccount(&account, 20)
ChargeAccount(&account, 18)
ChargeAccount(&account, 12)
fmt.Printf("%+v\n", account)
}
//will ultimately print -51
You can try these yourself:
NodeJS: https://replit.com/talk/share/Async-race-condition/138001
Go: https://play.golang.org/p/ypKfQFUF5M2
Edit:
> Explicit async-await makes it more obvious that multiple invocations of ChargeAccount might interleave unless proven otherwise, and the programmer has a reason to confirm that it is not the case, or use locking / atomics as necessary.
As shown by the example above, I think almost the opposite is true: async/await take code that looks linear and single-threaded and make it run concurrently. It's easy to accidentally end up with code like the JS one. Even worse, it's easy to accidentally call async code without await-ing it at all, and have unexpected behavior.
By contrast, transparent async + cheap coroutines means that you can make all of your APIs blocking, and rely on the user explicitly spawning a coroutine if they want any kind of parallel/concurrent behavior, at the call site. In the Go code, I could have called `go ChargeAccount(...)` if I wanted the ChargeAccount calls to happen in parallel - I would have explicitly created parallelism, and I would have known to be careful of sharing the same pointer with the coroutines running concurrently.