-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathasync_programming.jl
More file actions
2088 lines (1682 loc) · 104 KB
/
Copy pathasync_programming.jl
File metadata and controls
2088 lines (1682 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
### A Pluto.jl notebook ###
# v0.20.4
using Markdown
using InteractiveUtils
# ╔═╡ 21b2cbda-17d6-4466-adbc-5e513482e1af
# Import some packages
begin
using PlutoUI
using BenchmarkTools
end
# ╔═╡ 8f6cf27f-5648-40ab-b029-d52c0eaf3883
using .Threads
# ╔═╡ 8b83e436-dc12-11ef-1b27-1d3fdefb24c1
md"""
# Asynchronous programming in Julia
_Written 2025-02-08_
**This notebook is hosted at viralinstruction.com**
**Find the source code at https://github.com/jakobnissen/julia_async**
When I read blogs or textbooks on programming, I'm struck by the diversity of vantage points from which people think about our craft.
A lot of blogs describe their programming as revolving around websites and networks, where themes like communication protocols and JavaScript frameworks play major roles.
In my eight years of scientific programming, I've never had to think about any of that stuff. To me, that's like a parallel universe of software which interacts very little with what I do, or care about, on my job.
Most of the blogs I read mention asynchronous programming in this context of 'website programming'. So, I thought that async was mostly about how your program handled waiting for network data.
An important subject, perhaps, but surely something I could ignore as a scientist.
Oh boy, was that wrong.
In this notebook, I'll dig into asynchronous programming in Julia.
I will begin with the most fundamental building blocks of async, and build up towards the more human-friendly high level async interfaces.
Let's begin.
"""
# ╔═╡ 0746182a-2e0e-4bc3-a8e5-459b8a7a6f28
# Make sure any benchmarking cells don't take too long
# to execute. Two seconds per cell should be enough.
BenchmarkTools.DEFAULT_PARAMETERS.seconds = 2;
# ╔═╡ b278c476-74e9-4399-a05c-7c15111bfd3c
TableOfContents()
# ╔═╡ 17ddbb08-cd8d-446e-b806-dc3000dc2184
md"""
## Why is async important?
Asynchronous programming means having different parts of your program in progress at the same time, although, as we will see, the meaning of "the same time" is elusive.
To do this, the programming language needs to somehow abstract over 'units of computation' as separate _tasks_ that can be started, paused, restarted and stopped.
In Julia, this is fittingly modelled with the `Task` type.
Tasks are most easily created with the `Threads.@spawn` macro, which will wrap a Julia expression in a zero-argument function, and then run that function as a task:
"""
# ╔═╡ 2bd97906-edc8-48e8-ac2d-6a87a2e01fe5
task = @spawn begin
x = 1 + 1
return x + 1
end
# ╔═╡ c4f33908-efab-4654-b9dd-b4e46573428a
md"""
Tasks are first _created_, then _started_, after which they can be _paused_ and _resumed_ an arbitrary amount of times during their lifetime.
When the function they wrap returns, the task is _done_.
If the function in the task throws an unhandled exception, the task has _failed_.
Failed and done tasks cannot be restarted.
To wait for the completion of a task and obtain its return value, use `fetch`.
"""
# ╔═╡ 5e45c378-3788-484c-906b-86586c8cd7c8
fetch(task)
# ╔═╡ a8acd763-7068-4a2c-9dd9-8f926680f8b7
md"""
The most common (but not _only_!) use case for tasks is to allow _parallel computation_, where multiple tasks are running at the same time.
The difference between asynchronous and parallel programming is that parallel programming explicitly means that multiple tasks are running at any one instant. Async programming is a broader term that includes parallel programming, but also includes situations where execution switches between tasks but only one runs at any given time.
>>>>>>>>>>>>>>>>>>>>>>> Time >>>>>>>>>>>>>>>>>
Async but not parallel
Task A -----> ----> -->
Task B ---------> --------> -->
Async and parallel
Task A ------------------------------------>
Task B ------------------------------------>
When tasks run, they do so on an underlying _thread_ provided by the operating system (OS).
The total number of threads currently needs to be set from command line when starting Julia using the command-line flag `--threads` (or `-t`, for short).
It's the job of the OS to distribute hardware resources (e.g. CPU time) among the threads.
A CPU core can only run one thread at a time, so the number of threads set by Julia is usually a small, fixed number corresponding to the core count of the CPU.
You can check the number of threads running in the current Julia process with the function `Threads.nthreads()`:
"""
# ╔═╡ de1f77c2-bdbb-4192-ba24-da41489c0a8b
Threads.nthreads()
# ╔═╡ f74b1284-dece-4216-bb06-29514415ff5f
md"""
Julia intentionally provide few abstractions to interact with the threads themselves, focusing instead on _tasks_ as the central unit of asynchronous computing.
As a programmer, your focus is supposed to be on managing the tasks, and you can usually trust Julia to do a reasonable job of running the tasks on all available threads in an efficient manner.
Precisely because the user is not supposed to think about threads, Julia has great freedom in which tasks are run on what threads.
At least abstractly, a task may be run on any available thread, started and stopped arbitrarily, and even moved between threads.
"""
# ╔═╡ ef36118e-2fdf-4c99-a9da-f8cbc6885fb3
md"""
To whet our appetite, let's demonstrate a toy use of tasks to achieve asynchrony.
One of the most basic design patterns is to spawn and fetch tasks within a single function. For example, in the following case:
"""
# ╔═╡ 67664ef0-0f00-4388-a47c-9d97d7b443a5
begin
simple_function_1(x::Int) = div(x, 2) + 1
simple_function_2(x::Int) = sqrt(x) + 9
function complex_function(x)
t = @spawn simple_function_1(x)
a = simple_function_2(x)
return (fetch(t)::Int, a)
end
complex_function(9)
end
# ╔═╡ 0f800d3f-34ae-4f6c-b6bc-d7c82c1c1af2
md"""
In `complex_function`, the calls to the two 'simple functions' do not depend on each other, and may be run in any order. Hence, we can run one of them as a separate task, which then runs in the background while the other simple function is executed. In principle, this pattern can double the speed of a function with the same structure as `complex_function`.
At the time of writing, Julia unfortunately cannot do type inference on `fetch` which always infers to `Any`.
Hopefully, that will be fixed in the near future.
Until then, I recommend you annotate the return value of `fetch` with the expected return type to obtain type stability.
"""
# ╔═╡ 20ee5cfa-b80c-4a2f-8314-67048c1c429b
md"""
## The law of async
Since async is all about splitting your program into stoppable and resumable tasks, converting synchronous code to asynchronous can invasive, in that it may reorganize the logical flow of your entire program.
Async code is also (deservedly) infamous for being tricky to reason about and prone to bugs.
To reduce the risk of bugs, it helps to internalize the cental law of async:
> Mutation requires exclusivity
That is, if one task mutates some data, no other task must access that data (read from or write to it) at the same time.
The reason is that most code relies on the assumption that data doesn't spontaneously change while it's being operated on.
If task A mutates some data while task B operates on it, from the point of view of task B, the data _does_ appear to spontaneously mutate.
In this spirit of legalism, let's write some sections of the central law:
§ 1a. The different elements of a Julia `Memory`, and therefore `Array`, are considered different data.
That is, it is allowed for two tasks to mutate
different elements of the same array concurrently.
§ 1b. Some operations superficially appear to only affect one element of an array, but
actually affects all of them. E.g. `push!` might cause the whole array
to be resized, which requires copying the memory of the whole array.
Therefore, such an operation counts as mutating _every_ element.
Likewise, the elements of `BitArray` are not independent:
Because multiple bits are stored in the same integer in an underlying `Array` in the bitarray,
mutating one element of the array mutates the whole integer underlying integer, and therefore mutates the data backing multiple elements in the `BitArray`.
§ 1c. If no task is mutating a piece of data, then it may be shared freely
among tasks. For example, multiple tasks may look up in the same
dict, or copy the same string, concurrently.
"""
# ╔═╡ a279c000-2154-44b9-bb72-41862b61fcbc
md"""
## Data races
Let's have a look at an example of what happens when you violate the law of async.
In the code below, `add_ten_million!` will increment an integer through a reference ten million times. The function `increment_occasionally` will read the same reference, add its content to a result, and then substracts the same number it added to the result from the reference.
You can envision this as modelling a task that records the current progress on some computation, and another task that occasionally displays the progress since the last update.
The code contains a function call to `Threads.atomic_fence`.
You can ignore this for now - I'll get back to it later.
"""
# ╔═╡ 0668560d-d2ff-49ae-a5ae-3a92ba269e53
function add_ten_million!(ref)
for i in 1:10_000_000
ref[] += 1
atomic_fence() # ignore this function call for now
end
end;
# ╔═╡ a40cd157-fde1-49be-9156-0b8caa12e5a5
function increment_occasionally(ref)
t = time_ns()
result = 0
while time_ns() - t < 1_000_000_000
increment = ref[]
result += increment
ref[] -= increment
atomic_fence() # ignore this function call for now
end
result
end;
# ╔═╡ fc99d8b6-9b32-46a4-bde0-9108b029e43e
md"""
Below, I run the two functions in parallel, where they work on the same ref.
Clearly, when they are done, the result will be ten million... right?
"""
# ╔═╡ 602c3a65-79c2-4e6b-a70d-c9b3c1df20ca
let
ref = Ref(0)
t = @spawn increment_occasionally(ref)
add_ten_million!(ref)
# Fetch the result and add anything left in `ref`
# not yet taken by `increment_occasionally`
fetch(t) + ref[]
end
# ╔═╡ 592fc034-8344-4cb7-b378-6ced7205ad9e
md"""
The result is _non-deterministic_ - every time you run it, it's likely to give a different number.
The reason it doesn't behave as expected is that both tasks mutate `ref` concurrently, violating the law of async.
We call such situation _data races_.
In this particular example, the problem occurs because of the details of how the line `ref[] += 1` is implemented.
The line `ref[] += 1` is equivalent to `ref[] = ref[] + 1` - actually three operations in disguise:
1. Load `ref[]`
2. Add 1 to the loaded value
3. Store the result back into `ref[]`.
Suppose now `ref` has a value of 5, and task A runs `add_ten_million` thus incrementing `ref`, and task B runs `increment_occasionally` and thus zeros `ref`.
There is is some chance that it could be executed in the following order:
1. Task A loads `ref[]` getting 5
2. Task B runs `increment = ref[]`, also getting 5
3. Task A computes `5 + 1`, getting 6
4. Task B subtracts 5 from `ref`, setting it to zero
5. Task A sets `ref` to 6
If that occurs, the subtraction done by task B will be undone when task A stores `6` back into ref, and therefore the 5 previous increments will be added twice to `result`.
Here, the underlying cause is that `ref[] += 1` is composed of several steps, and that the other task is able to read or modify data while it is in the middle of this series of steps.
In computer science terms, we say that the problem is that `ref[] += 1` is _not atomic_. Here, "atomic" is used in the original Greek sense, meaning _indivisible_.
An atomic operation is one that can never be observed is a state of partial completion - it either has not happened yet, or is already complete.
#### Even single CPU instructions are not atomic
It is tempting to try to solve data races like the one above by simply choosing operations which are not implemented in terms of multiple smaller operations.
But if you look into the generated assembly code for the `add_ten_million!` function above, you will see that the line `ref[] += 1` is compiled to a single instruction - at least on my computer with a x86-64 CPU.
So naively, one would think that this single instruction would be atomic - not composed of multiple, smaller steps. Nonetheless, the data race happened. Why?
In the CPU, even single CPU instructions may be executed in terms of smaller _micro-operations_, the details of which is an implementation detail of the CPU. Furthermore, the CPU's memory system is complex and multi-layered, and there is no guarantee that when a computer stores a value to memory, other parts of the CPU will immediately be able to see the stored value.
Finally, on the programming language level, while Julia might compile the increment to a single instruction right now, there is no guarantee that the compiler will generate the same assembly code in the future, making it pointless to write code based on the exact assembly instructions that are generated.
This will be a recurring theme in this notebook: The rules of async are abstractions that can't easily be explained in terms of the underlying implementation, because the implementations are complex and opaque.
As a programmer, your best bet is to adhere to the abstraction and not try to outsmart it by peeking under the hood.
Once we know that
1. All CPU operations may be split into multiple steps in the CPU and the memory hierarchy, and
2. Interacting with data that is in a partially processed state may cause a data race,
we find ourselves forced to conclude that no task can ever mutate data being shared by other tasks at all, and so the prospect of writing asynchronous code appears completely hopeless.
## Atomic operations
Fortunately, Julia provides dedicated _atomic operations_ to address this problem. The compiler guarantees that these operations are always compiled down to dedicated atomic CPU instructions, which the CPU in turn guarantees are actually atomic.
Let's try to fix the bug using atomic operations.
To use atomic operations in Julia, we need to use a mutable struct, with the relevant field marked `@atomic`:
"""
# ╔═╡ a5fa1503-5880-4d0e-aba8-0c3ee34b6dfa
mutable struct MyAtomic{T}
@atomic x::T
end
# ╔═╡ 422c8758-e0f6-491e-ad0d-743f4d9e7c48
md"""
We can now rewrite the functions above, using this atomic integer in place of our old `Ref`.
Note that all operations on atomic fields needs to be marked `@atomic`.
"""
# ╔═╡ 107291d1-1c00-45a7-9225-be541ff44ab6
function add_ten_million_atomic!(atomic)
for i in 1:10_000_000
@atomic atomic.x += 1
end
end;
# ╔═╡ 4266d974-0dca-4798-9952-2a7e8c77968c
function increment_occasionally_atomic(atomic)
t = time_ns()
result = 0
while time_ns() - t < 1_000_000_000
increment = @atomic atomic.x
result += increment
@atomic atomic.x -= increment
end
result
end;
# ╔═╡ 6a1c5924-92b6-40ef-967f-494332ade500
let
atomic = MyAtomic{Int}(0)
t = @spawn increment_occasionally_atomic(atomic)
add_ten_million_atomic!(atomic)
fetch(t) + @atomic atomic.x
end
# ╔═╡ 718eac91-b4d1-4dee-ac67-76e4cb8ef341
md"""
Voila! The bug disappeared.
## Memory re-ordering
Atomic operations have a _memory ordering_ associated with them.
To understand memory ordering, it is necessary to take a detour and look at how memory re-ordering happens in normal non-atomic code that is executed within a task.
### Re-ordering within a task
We begin by looking at the simple Julia function below:
"""
# ╔═╡ fde5713b-2774-43dc-90d1-36b1446d4540
function order1()
b = 1 + 1
a = 1
a = 2
return a + b
end;
# ╔═╡ 19609694-3c8e-4b46-be18-63011e315308
md"""
As we know and love, the Julia compiler will make sweeping changes the code we've written in the name of optimisation.
For example, it may evaluate `b = 1 + 1` to `2` at compile time, then move this computation down onto the last line, such that it becomes `return a + 2`.
Similarly, it may delete the redundant `a = 1` line, since `a` will be overwritten immediately after, anyway.
But wait: If the compiler is allowed to _both_ shuffle around the code, _and_ delete redundant stores, why can't it reorder `a = 2` to come before `a = 1`, and then delete the now-redundant `a = 2` line instead?
The obvious answer is that the compiler must have a notion of _dependent data_: Computing `b` has no dependency on the computation of `a`, and so may be moved around freely with respect to `a`.
In contrast, `a = 1` and `a = 2` are dependent on each other, since they mutate the same variable.
Generally, data (i.e. a variable or a memory location) `A` depends on data `B` if `B` is being used to mutate `A`.
Dependent operations must have a notion of _happens before_, i.e. `a = 1` happens before `a = 2`, so these two lines can't just be rearranged with respect to each other.
Once again, I want to stress that _happens before_ is an __abstraction__.
In practice, the function `order1` will compile down to simply `return 4`, and when the program runs, there won't exist any data that corresponds to the variables `a` or `b`.
Nonetheless, we can still unambiguously say that `a = 1` happens before `a = 2`.
As we've seen before, if memory is being mutated, it can _only_ be shared between tasks if the mutation is atomic, since sharing non-atomically mutated memory may cause a data race.
For this reason, barring atomic operations, data in different tasks cannot have a valid data dependency of each other, and as a consequence, only atomic operations can establish a happens-before relationship between two tasks.
### Happens-before relationships between tasks
Let's look at the happens-before relationships in the code below and consider what that implies for the result
"""
# ╔═╡ 9fe22bc2-2c4a-423d-85cb-60a16ad68ea3
function overwrite(a::Ref{Bool}, b::Ref{Bool})
a[] = true
b[] = a[]
end;
# ╔═╡ 00e9e859-3238-41f8-93ca-b2243495083b
function observe_overwrite()
a = Ref(false)
b = Ref(false)
t = @spawn overwrite(a, b)
b[] ? a[] : true
end;
# ╔═╡ b74ae921-f376-4a40-b042-4e7a777902ef
md"""
Here, `observe_overwrite()` may return either `false` or `true`.
That may surprise you. After all, we may naively reason that:
* `b[] = a[]` is guaranteed to happen after `a[] = true`, since `b` depends on `a`, and `b[] = a[]` is placed after `a[] = true` in a function running in the same task.
* Therefore, if we observe `b[]` is `true`, so must `a[]` be.
* Therefore, in the expression `b[] ? a[] : true`, if `b[]`, then it returns `a[]` (which must be `true`), and if not `b[]`, then it returns a literal `true`.
* Therefore, `observe_overwrite()` will always return `true`.
Right?
Not so. What we missed with the above analysis is that, absent atomic operations, there is no notion of happens-before between tasks. So, the function `observe_overwrite` could observe the operations in `overwrite` in any order.
Remarkably, this includes the reality-warping order where `b` stores `true` __before__ `a` does, despite `b` supposedly loading its value from `a`!
Therefore, `observe_overwrite` could plausibly load `true` from `b[]`, and then return a `false` from `a[]`!
We can construct even more cursed situations where the lack of happens-before between tasks mean that one task will implicitly observe another task doing its operation in an absurd and seemingly impossible order.
We _could_ then try to explain why this can happen in terms of the complex underlying implementation in the CPU and memory hierarchy, but as I previously said, there's little point in trying to peek behind the curtain. Just use atomics when sharing data between tasks, or else you'll get bugs.
### Atomic memory orderings
It's important to keep in mind _why_ our compiler, CPU and memory re-order operations, making asynchronous code so damned hard to reason about: Speed.
Computers could be built perfectly synchronously with no out-of-order execution, but they would run tens, or hundreds of times slower.
The more we restrict out-of-order computation using atomics, the slower our program runs.
Ideally, we want to place only the exact amount of re-ordering restrictions to allow our async code to be correct, but no more.
Therefore, atomic operations come with a selection of _memory orderings_, such that we can pick the most lax ordering that allows some optimisations, while still making our program work correctly.
#### Ordering: Sequentially consistent
The default memory ordering, used if not explicitly specified, is also the strongest one: _sequentially consistent ordering_.
When a sequentially consistent operations happens, then
* All tasks are guaranteed to be able to observe it
* All tasks are guaranteed to also be able to see writes that occurred before the operation
* All tasks are guaranteed to not yet be able to observe writes that occur after the operation
In this way, a sequentially consistent operation acts like a memory barrier that imposes some order to inter-task operations: No operations can be re-ordered across the barrier, either in the before => after direction, nor in the after => before direction, no matter what task you are observing from.
We can fix the above example with a sequentially consistent operation:
If the observer task atomically reads `b.x` as `true`, the atomic operation `b.x = a[]` is fully completed, in which case `a[] = true` is guaranteed to have been completed due to the memory ordering guarantee of sequentially consistent.
"""
# ╔═╡ 65fc3a2d-de89-4cc2-8f1e-dbc2f90c416d
function overwrite_atomic(a::Ref{Bool}, b::Atomic{Bool})
a[] = true
# Sequentially consistent ordering is default,
# so we could have omitted it here.
# The memory ordering means that any task that observes `b`
# to be true must also be able to observe a to be.
@atomic :sequentially_consistent b.x = a[]
end;
# ╔═╡ a08bd0d6-776e-46a7-a186-614cc0bbd65d
function observe_overwrite_atomic()
a = Ref(false)
b = MyAtomic(false)
t = @spawn overwrite(a, b)
(@atomic :sequentially_consistent b.x) ? a[] : true
end;
# ╔═╡ fd87476d-b32b-4785-9fdc-21da66b9adaa
md"""
The mysterious `atomic_fence()` function called in the data racey, non-atomic example above inserts a sequentially consistent memory fence, without doing any actual atomic operations.
It was needed in the first example to prevent Julia from being too clever and hoisting the increments outside the loop, thereby preventing a data race and foiling my example.
#### Ordering: Monotonic (or relaxed)
At the opposite end from sequentially consistent ordering, we have the _monotonic_ ordering, also called _relaxed_ ordering in other languages.
This ordering provide _no restrictions_ on memory re-ordering, allowing the computer full freedom to re-order operations around for maximal performance.
Consider the example in `add_ten_million_atomic!`.
Here, we don't really care if the compiler moves around the atomic loads or stores, e.g. by unrolling the loop, or even by the compiler moving the atomic increments outside the loop, and switching the ten million atomic increments to a single atomic addition by ten million.
For this reason, that example would have been best solved by using monotonic atomic operations.
"""
# ╔═╡ 27133bd1-433e-4bc4-ac6e-c06b9c245b6e
md"""
#### Ordering: Acquire and release
It turns out, that, most of the time when we _do_ care about memory ordering, we don't require the kind of complete memory barrier that the sequentially consistent ordering provide.
One of the most common scenarios in async programming is when one task computes a value, then atomically modifies a flag to signal the value is ready to be read by another task.
Meanwhile, another task reads the flag, waiting for it to be changed before the task loads the value and continues processing it.
An example could look like this:
"""
# ╔═╡ e07e174d-3719-4025-91aa-f56365a68453
function mark_when_ready(is_ready, shared)
sleep(0.2) # do some computation
shared[] = 42
@atomic :release is_ready.x = true
end;
# ╔═╡ d11037b5-241d-4878-9602-0043bb21e72b
function return_when_ready(is_ready, shared)
while !(@atomic :acquire is_ready.x)
# Only check once every 10 miliseconds
sleep(0.01)
end
shared[]
end;
# ╔═╡ 2a383feb-22d8-48d8-b247-c20ec5bbe91b
md"""
Here, in `mark_when_ready`, it's crucial that no operations are moved in the before => after direction across the atomic modification of `is_ready`.
For example, if `shared[] = 42` was moved to after the atomic operation, then the other task might use `shared` before it was ready.
But the opposite isn't true! It would be no problem if the compiler moved some non-atomic operations in the after => before direction across the atomic operation.
In `return_when_ready`, it's the opposite situation: `shared[]` cannot move in the after => before direction across the atomic load of `is_ready`, but it would not be a problem if there was some operation above the atomic load that was moved to after.
These kinds of situations are what the _acquire_ and _release_ orderings are used for: The release ordering is used for a write operation to release some data to another task, and the acquire ordering is used for a read operation to access data modified in another task.
They each create one-way memory barriers; release creates a before => after barrier, and acquire an after => before barrier.
"""
# ╔═╡ 606f9ce3-90f2-442c-aac7-d2a24a61f180
md"""
### Atomic swap
There are situations where atomic reads and atomic writes are not enough to ensure synchronization between threads.
The example below is similar to the previous example, but now two tasks are waiting to process the data at the same time - we can call these _consumer tasks_.
In this example there is no real need to have multiple tasks waiting since only one of them can do any work, but one could easily imagine that the consumer tasks process not one element, but a stream of data produced by the main producer task.
Here, we use an extra atomic boolean `is_done` to signal to the consumer tasks that they should stop waiting for the data to be ready:
"""
# ╔═╡ 69652455-0e9f-4b2a-bbef-464841dc9bc5
begin
function use_data_when_ready(
data::Ref{Int},
is_ready::MyAtomic{Bool},
is_done::MyAtomic{Bool},
)
# Check atomically if data is ready to be processed
while !(@atomic :acquire is_ready.x)
# If the other task already processed data, return
(@atomic :acquire is_done.x) && return nothing
# Only check once every 1 millisecond
sleep(0.001)
end
# Signal other task should not process the data,
# but should instead exit
@atomic :release is_ready.x = false
@atomic :release is_done.x = true
println(data[]) # Process data
end
function run_example()
data = Ref(0)
is_ready = MyAtomic{Bool}(false)
is_done = MyAtomic{Bool}(false)
# Spawn two tasks to process the data
tasks = map(1:2) do _
@spawn use_data_when_ready(data, is_ready, is_done)
end
sleep(0.1)
data[] = 1
@atomic :release is_ready.x = true
foreach(fetch, tasks) # wait for tasks
return nothing
end
end;
# ╔═╡ 175bc3f1-1a25-4d0a-b454-44b34b5efe11
run_example()
# ╔═╡ f2f2e703-9874-4872-958b-d653f9a8365a
md"""
The above example has a synchronization bug. Can you spot it?
It is possible for the two consumer tasks to simultaneously read `is_ready` and break out of the while loop, before either of them is able to set `is_ready.x = false` to disable the other task. It's unlikely, but absolutely possible.
The underlying problem is that, while both the reads from, and the writes to `is_ready` are individual atomic operations, that's not enough in this example.
It will cause issues if task B reads `is_ready` in between task A reading from and writing to it.
What we need here is to do _both_ the reading and the writing as one single atomic operation.
For this, we can use the `@atomicswap` macro. This sets a field and reads the old value in one atomic operation.
In most other programming languages, atomic swap is called _atomic exchange_.
We can use this to rewrite the while loop in the example above like so:
```julia
while !(@atomicswap :sequentially_consistent is_ready.x = false)
(@atomic :acquire is_done.x) && return nothing
sleep(0.001)
end
```
This will _guarantee_ that, if `is_ready` is only set to `true` once by the producer, then only one of the consumers will break out of the while loop.
"""
# ╔═╡ 9d335a94-9b57-4624-a0f0-b54829a951e5
md"""
#### Ordering: Acquire-release
In the `@atomicswap` example above, I used the sequentially consistent memory ordering. This is because we need both the read and the write to be atomic at the same time, and the acquire and release orderings are only for reads and writes, respectively.
In reality, this is exactly the kind of situation where the _acquire-release_ ordering is supposed to be used. This ordering provides both the guarantees of the acquire ordering and the release ordering.
So, if acquire-release both places a memory barrier in the before => after direction (like `:release` provides) and an after => before barrier like `:acquire`, what is then the difference from sequentially consistent?
Honestly, I still don't understand. Apparently, sequentially consistent is even more strict than acquire-release, because only sequentially consistent operations are guaranteed to be part of a global, total modification order - whatever that means.
"""
# ╔═╡ fc8d9128-9788-416f-b387-575c87e73360
md"""
### Atomic replace
The most advanced operation operations are the _atomic replace_ operations, also called _atomic compare and swap_, or _atomic compare and exchange_.
An atomic replace is like a conditional swap: The value is swapped, but only if the old value was equal to some expected value.
In Julia, the macro `@atomicreplace atomic.fieldname expected => new` works just like the below function, except that it does everything in a single, atomic operation:
```julia
function atomic_replace(atomic, fieldname, expected, new)
old = getfield(atomic, fieldname)
success = old == expected
if success
setfield!(atomic, fieldname, new)
end
return (; old, success)
end
```
These operations take _two_ memory orderings: One to be followed if the swap is successful, and another if the swap failed.
Atomic replace is used, for example, to atomically increment or decrement an integer.
In Julia, the atomic operation `@atomic x.x += i` is rewritten by the compiler to a while loop using atomic swap, similar to this below:
"""
# ╔═╡ 62b369f9-15f7-4737-8dda-40211f8f22c0
function atomic_add(x::Atomic{Int}, i::Int)
while true
old = @atomic :monotonic x.x
if (@atomicreplace x.x old => old + i).success
return old + i
end
end
end;
# ╔═╡ c82ec1aa-dd68-42e1-9ff0-1e1222c916f3
md"""
The while loop is necessary, becaue another task might change `x` between `old + i` is computed and the atomic replace occurs. If that happens, then the atomic replace will fail because the atomic is no longer the expected value `old`, and the loop will restart.
The loop will only break if either `x` is not modified between the two atomic operations, or it's modified by e.g. incrementing and decrementing by one, such that the value of `x` is unchanged.
Therefore, even though this is an whole while loop, it acts atomically when viewed from the outside.
"""
# ╔═╡ 69b86287-ad33-486a-9b70-b4eacb443f43
md"""
### Atomics are mostly used to implement other async abstractions
Atomic operations provide the lowest level abstractions for async, being essentially async-friendly single CPU instructions.
They aren't exactly user friendly though. Not only because of their low level, but also because their memory ordering and happens-before relationship is tricky to reason about.
Direct use of atomics can also be extremely _inefficient_.
For example, the `return_when_ready` function implemented above will continuously check whether the shared value is ready, consuming CPU cycles in the process.
It would be much better if the function instead could be paused and only resumed once the value was ready.
In practice, most use cases of async don't directly use atomics, but will instead control tasks through more user-friendly higher level abstractions implemented in terms of atomic operations.
Before we turn to those abstractions, let's look at what makes up a task itself.
"""
# ╔═╡ 1d70bc25-b941-4481-8579-80b70e7b6846
md"""
## Tasks and task switching
In order to be suspended and resumed, a task needs to keep track of its current progress. The progress of a task, or its state, is comprised of two parts:
First, the current state of the CPU registers. If the compiler statically knows all the points in the code where a task can be suspended, the compiler may make sure only a small subset of the registers are in use at that time, such that the task needs to store less state.
As we will see, in Julia, when Julia tasks yields control to other tasks, it's always voluntary, and so the compiler is able to do this optimisation.
Second, a task needs a _stack_. This is the same kind of basic stack used by all
programs, which we know and love from e.g. stack overflow errors.
The stack is analogous to a `Vector{UInt8}` - a region of memory
with a pointer that keeps track of how much of the region is in use at any given time.
The two main operations the stack can do is _pushing_ (analogous to `push!`), which
adds an element to the end of the stack, and popping (i.e. `pop!`),
which removes the last pushed element.
All information about the program progress that cannot be kept in the registers is
stored on the stack.
When the register state is saved on task suspension, the
registers are typically pushed onto the stack; conversely, they are popped from
the stack into the registers upon resuming the task.
This ensures that the stack contains all the information needed to resume a paused
task.
In Julia, tasks carry their own independent stack. That makes them easier to work with, but makes them cost around 16 KiB memory to instantiate.
"""
# ╔═╡ 6b900c42-127e-463f-b941-c321297537f3
md"""
### How does task switching work?
Conceptually _and_ implementation wise, there are several similarities between
a _function call_ and a _task switch_.
At a function call, the CPU will pause the execution of the current function and
give control to a different piece of code, which is then automatically returned to
when the function returns. The parallels to task switching are obvious.
So: How do function calls work?
In x86-64 CPUs, the rip (register instruction pointer) register stores
the memory location of the next instruction to be executed by the CPU.
To begin executing a function, we need to change the value of this register to
point to the first instruction of our callee.
Changing the value of the rip register is done with a _jump_ instruction -
i.e. we say that the program jumps to some memory location.
However, first, the CPU needs to make sure it can resume the work when the callee
returns.
By convention, on Linux on x86-64 computers, the seven registers rsp, rbx, rbp,
and r12-r15 are so-called _callee saved_. This means that no function is allowed
to change these registers when being called: Either they must leave the registers
alone, or else they must make sure to push the original state of the registers to
the stack, and pop them from the stack back into the registers, in order to restore
them, before returning.
Therefore, any _caller_ can assume no callee changes these registers, and can store
information in them. Any state that can't be kept in these registers are pushed to the stack.
Aside from the callee saved registers, the CPU only needs to store the aforementioned rip register on the stack, in order to be able to return the execution to where it left off, and thus fully restore the CPU state.
So, to call a function, the CPU needs to:
1. Store all local state in either the seven callee-saved registers or on the stack,
2. Push the rip register to the stack, to save the exact location where the call happened, such that the code can jump back to the location upon function return
3. Move the memory location of the callee into the rip register
The `call` assembly instruction will do the last two points and comprise the actual function call itself.
When the callee has been executed, and control needs to return to the caller,
this is what needs to happen:
1. Clean up the stack by popping any data off it, such that it's in the same state
the callee found it in
2. Pop the stack into the rip register. Since the last element on the stack placed
by the caller was the rip register, doing this returns execution to the instruction
immediately after `call` in the caller, and allows the caller to continue executing.
The `ret` instruction will pop the last element of the stack into the rip register, thus returning from the function.
We can use the same general approach when switching tasks:
When a task gives away control to another task,
it pushes its callee-saved registers and the rip register to the stack.
To resume control of a task, all it needs is a pointer to its stack, from which
it will pop offs its register state and then resume execution by popping off the
rip register with a `ret` instruction.
I don't know how it's actually implemented in Julia, but an example implementation in x86 assembly could look like this:
"""
# ╔═╡ e06fcf86-8f30-4d9e-9f29-1a8705907999
md"""
```
; Store on the stack the address of the code immediately
; after this block, so that when execution returns from
; this address, the task will continue after the yield
push rip + <how many bytes this whole block is>
; Save the state of the registers on the stack, except rsp
push r15
push r14
push r13
push r12
push rbx
push rbp
; Store the stack pointer in the Task object itself, so when
; this task is run again, the stack is in the right state
mov [r11] rsp
; Now switch the stack to whatever is stored in the rdi register,
; where we point to the stack of the new task to execute
mov rsp [rdi]
; Restore the state of the CPU registers from this new stack
pop rbp
pop rbx
pop r12
pop r13
pop r14
pop r15
; The return instruction pops into the rip register
; and since the task pushed memory address after its own
; yield, the new task will begin resuming from where it
; yielded
ret
```
"""
# ╔═╡ 725773df-24c7-4547-84fc-3cd163d19136
md"""
### Tasks usually switch to the scheduler
In Julia, tasks can switch to other tasks with the low-level `yieldto` function.
This is not usually practical: This would require every task - i.e. every piece
of user code - to be aware of which other tasks are awaiting to be switched to,
and also to know when to switch to them. How could a library developer possibly
know what other code is running in a given session that should be switched to?
Instead, a program called the _scheduler_ keeps track of all tasks in the process.
The scheduler is a C program that is part of the Julia runtime, similar to the garbage collector.
Having a single centralized program to control task switches makes things much easier for the programmer: Every task simply switches to the scheduler, which controls which task to switch to next.
If the Julia process has multiple threads, the scheduler may run multiple tasks in parallel.
User code may switch to the scheduler explicitly with the `yield` function.
More commonly, yields are built into a number function calls in Julia:
* Memory allocation, including during dynamic dispatch will occasionally yield
* Interaction with outside sources, like IO will usually yield
* Many operations on tasks and async-friendly data structures will yield
#### Blocking and non-blocking IO
When a Julia program needs access to your computer's resources, such as when opening a file, Julia needs to interact with the operating system to request them.
Especially for IO-related resources like the file system and network data, they may not be immediately available. What's the rational thing to do then?
Here, we distinguish between _blocking_ and _non-blocking_ operations. When executing a blocking operation, the program will halt and wait for the resource to be available, before progressing. In contrast, a non-blocking operation will return some kind of object representing a soon-to-be-available resource, and immediately return. The code can then intermittently check the object whether the resource has become available yet, and switch to other tasks to do useful work in the meantime.
In Julia, all IO is non-blocking _from OS' point of view_, in the sense that the OS, when a resource is requested, will return control back to the Julia scheduler immediately and alert the scheduler when the resource is available.
However, from the point of view of a _Julia task_, IO is always blocking, in the sense that the scheduler will make sure to not schedule the task that requested the resource until the resouce is ready.
Therefore, in Julia lingo, when we talk about a blocking operation, we refer to an operation which yields control to the scheduler, and where the scheduler won't reschedule the task until the blocking operation is ready to proceeed.
We will return to various blocking operations later in this notebook, but let's see a simple example of the difference between blocking a whole thread on the OS level, and blocking a task on the Julia level while being non-blocking on the OS level:
"""
# ╔═╡ 4d700978-39e5-49c7-86ac-a3d0a7a724c1
begin
# This call's libc's sleep, which blocks the whole OS thread
blocking_sleep(x::Int) = @ccall sleep((x % UInt)::UInt)::Nothing
function run_sleep_function(f)
tasks = map(_ -> @spawn(f(1)), 1:Threads.nthreads() * 2)
foreach(wait, tasks)
end
end;
# ╔═╡ 8d7d65f8-bb2c-4b9d-ba68-f9631af0fcb9
# Block on the OS level
@time run_sleep_function(blocking_sleep)
# ╔═╡ a9c77789-b4d0-4e17-879b-5237b60cc5d6
# Block on the Julia level, but be non-blocking on the OS level
@time run_sleep_function(sleep)
# ╔═╡ 959a52ff-b76a-4902-b822-2d63d0aa88bf
md"""
When `blocking_sleep` runs, it blocks the whole OS thread - that is, the OS does not allocate any resources to the thread. The Julia scheduler is not aware of how the OS allocates resources to threads, and so from the its perspective, the task appears to be normally running, except that it happens not to yield.
Therefore, with N threads, the scheduler only runs N tasks in parallel.
When Julia's own `sleep` function runs, the task is blocked, and thus control is immediately given back to the scheduler. The scheduler now has a thread free, and will start the next task on that thread (here, another sleeper).
Within less than a millisecond after launching `run_sleep_function(sleep)`, the scheduler will have started all 2N sleeping tasks, possibly even from the same thread, such that their timers run in parallel.
Note that the timer used by `sleep` does not need to occupy a thread to keep running, therefore neither of the 2N tasks will consume any significant amount of CPU time.
"""
# ╔═╡ 766372a3-fb2c-4e86-8916-37bcaf9c0d79
md"""
### The Julia scheduler and the OS scheduler
The purpose of the Julia scheduler is to run Julia tasks on the limited number of threads provided by the operating system.
Your operating system (OS) also has a scheduler, whose analogous job it is to map threads onto the limited number of CPU cores provided by the hardware.
This raises a question: If the OS _already_ has a scheduler which maps an arbitrary number of threads onto your CPU cores, why does Julia even bother with a scheduler itself? Why doesn't every task simply spawn an OS level thread, and then let the OS efficiently manage the threads?
As usual, the reason is efficiency. The OS scheduler needs to keep more state related to each thread, including the per-process available memory. Also, since the OS needs to handle a more varied set of events like e.g. a signal from the network card or the keyboard, the OS scheduler is more complex and needs more book-keeping.
All this means that creating and managing OS-level threads is slower than managing Julia-level tasks.
Tasks which are managed by the language runtime's own scheduler, and which can be switched to and from without interacting with the operating system are also called _green threads_, and are also used in other languages such as Go.
"""
# ╔═╡ fc185cbe-37a2-45ba-bb5c-de3441722a70
md"""
### Cooperative multitasking
We've seen how one task is able to yield control to the scheduler (or another task).
A system of asynchronous programming that relies on tasks freely yielding control is called _cooperative multitasking_, as opposed to a system where the scheduler is able to stop other tasks, called _preemptive multitasking_.
For now, as of Julia 1.12, Julia's system of async is entirely cooperative: Tasks must yield explicitly or implicitly, to be stopped.
Unfortunately, it's pretty easy to write code that does not yield, including doing any implicit yielding such as allocating memory, but which nonetheless can run for a long time.
For example, the naive implementation of the fibonacci function:
"""
# ╔═╡ 87a0896d-38a8-4b44-84b9-8d35a284338d
fib(x) = x < 2 ? x : fib(x - 2) + fib(x - 1);
# ╔═╡ 5e644139-6db6-49a9-ab68-7ad8c872d483
# No allocations, no yielding. But if run with a larger number,
# it can take a long, long time.
@time fib(37)
# ╔═╡ 49629d75-2824-4938-999f-d02b65cc8c29
md"""
In the _best_ case scenario, scheduling a long-running task which doesn't yield will prevent other tasks from being run.
As we will see in a moment, non-yielding tasks can have even worse consequences.
As a programmer, the best policy is to never write tasks that don't occasionally yield.
"""
# ╔═╡ 5688d79a-0593-4722-b4f6-252b327746b2
md"""
### Tasks and the garbage collector
When the garbage collector (GC) runs, it mutates the data structure that keeps track
of heap allocations.
As the golden rule of async goes, _mutation requires exclusivity_.
That means no other task can allocate memory at the same time as the garbage collector runs.
Practically speaking, this means that when one tasks triggers the GC, the GC can't run until all other tasks have been blocked, lest they allocate and cause a data race in the GC.
For this reason, we say that Julia's GC is a _stop-the-world GC_.
In turn, that means that all running tasks need to know that the GC wants to run,
such that they can block.
How is this coordinated?
When the GC wants to run, it modifies a globally available pointer, such that it points to an invalid memory location.
The function `GC.safepoint()` loads data from this pointer. If the pointer is
invalid, this triggers a SIGSEGV (segfault signal), which is handled by Julia's custom SIGSEGV handler, to block the current thread until the GC has been run.
If the pointer is valid (i.e. the GC has not signalled it wants to run), this pointer load has no effect and takes only half a nanosecond.
As of Julia 1.12, safepoints are automatically inserted into any non-inlined function call, so the only way for a task to _not_ regularly hit safepoints is if the task only executes a single, tight loop.
The inter-thread coordination needed to run the GC impacts how the user needs to write multithreaded code:
First, allocation-heavy code should be expected to scale worse with the number of threads than non-allocating tasks, because each thread creates garbage, so the GC needs to run more often.
And whenever it _does_ run, it needs to wait for every thread to reach the next safepoint.
Second, users need to be wary not to write code where _one_ task allocates memory, triggering the GC, when _another_ is running code that does not hit a safepoint.
If this happens, the first task will trigger the GC, blocking all tasks with safepoints, while the safepoint-less task will continue to run.
That means your multi-threaded workload will inadvertently turn single-threaded, potentially making it several times slower.
Because safepoints are inserted into all non-inline function calls, there are only two situations where this can be expected to occur:
* If a task is stuck in a simple, tight loop
* When a task executes non-Julia code, e.g. when calling a C library which naturally don't have safepoints for the Julia GC.
We can demonstrate the consequences of heavy allocation in parallel tasks, with and without a badly behaving task that doesn't yield.
"""
# ╔═╡ 58203da0-c8ae-418c-a175-e1df5e3c6272
# This function just allocates a lot
function allocate_lots(N)
v = Any[]
for i in 1:N
push!(v, ["a"])
end
sum(i -> first(codeunits(first(i))), v; init=0)
end;
# ╔═╡ 888e0763-54ce-4324-a55f-5f012b39d1b9
@time allocate_lots(2_000_000);
# ╔═╡ 1ba3f7e7-62b0-4843-b060-5df890ebf15b
function spawn_allocate(allow_safepoint::Bool)
f = allow_safepoint ? sleep : blocking_sleep
# 1 task with a task that either allows yielding (sleep)
# or doesn't (blocking_sleep)
tasks = [@spawn(f(1))]
# Saturate rest of the threads with tasks that allocate
for i in 1:Threads.nthreads() - 1
push!(tasks, @spawn(allocate_lots(2_000_000)))
end
foreach(wait, tasks)
end;
# ╔═╡ 1aa2b99b-8708-4a02-844a-02620562f1d4
@time spawn_allocate(true)
# ╔═╡ 4c749d70-e0aa-452c-b2a2-009b07fed30a
@time spawn_allocate(false)
# ╔═╡ fcaf0616-07ac-4458-b19a-2d1f452bbf36
md"""
Let's go through the example:
The only interesting property of the `allocates_lots` function's is that it... allocates a lot. If call the number of available threads N, the `spawn_allocate` function spawns N - 1 tasks running `allocate_lots`, and then one task that sleeps.
Its `allow_safepoint` argument determines if the sleep function it runs allows the GC to run concurrently: The built-in Julia `sleep` function blocks the task it's running on and therefore _does_ allow GC, whereas the libc sleep function directly puts the OS thread to sleep and will, from the viewpoint of the Julia scheduler, keep the task busy with no GC safepoints until it finishes.
First, we time how long a single task completes `allocates_lots`. The timing will vary quite a bit depending on the run, the computer, and the version of Julia, so I'll just use the timings I got when I ran it while writing this: It took around 0.5 seconds.
Then, we run `spawn_allocate(true)`. Since the sleep function here allows GC, this is equivalent to running N-1 instances of `allocate_lots` in parallel.
Ideally, this would also take 0.5 seconds, as each task can run at the same time on a thread each.
However, in reality, it took 1.2 seconds.
That's because the runtime of `allocate_lots` is dominated by allocation and garbage collection, and N - 1 tasks can create way more garbage for the GC to handle than a single task.
This demonstrates how memory allocations slow down multithreaded code more than single-threaded code.
Finally, we also time what happens when the sleep function neither yields to the Julia scheduler, nor has a GC safepoint.
Here, it took 2.1 seconds - almost a whole second longer than the previous multithreaded example.
What happens here is that the N - 1 allocating tasks, quickly after starting, signal to the GC that it needs to run. The GC then pauses all running tasks at their next GC safepoint.
However, the non-yielding sleep, running `libc`'s sleep, neither yields nor has safepoints, and so the GC needs to wait a whole second for the sleep to finish. Only then , and then can the GC run and restart the N - 1 waiting tasks.
In effect, the non-safepoint sleep call completely disabled parallelism while it was running.
"""
# ╔═╡ 545e5844-1624-4f5d-b0bc-fa900cd8562c
md"""
### False sharing
A modern CPU will cache recently accessed memory in a faster CPU cache, in order to speed up future access to the same memory.
In modern, multi-core CPUs, some of the cache may be shared between cores, whereas other part of the cache is core-specific. Keeping the caches _coherent_, i.e. making sure that different parts of the cache, each with a CPU core that writes it, all agree on what is actually in memory at any given time is a massive coordination headache.
Let's all appreciate the hardware designers who have worked hard to solve this problem for us programmers - as long as we remember to use atomic operations, we can mostly ignore the problem of cache coherence.
_Mostly_. There is one case I know of where the problem of cache coherence rears its head and appears to us programmers, and that is _false sharing_.
When the CPU cache copies data, it copies whole _cache lines_, usually 64 consecutive bytes, depending on your specific CPU model.
The CPU's _cache coherence protocol_ keeps track of which cache lines has been altered by one core. If another core requests the same cache line, the line needs to be synchronized between cores.
This has implication for asynchronous code: If one task mutates a piece of data, then all other data allocated on the same cache line will be slower to access for tasks running on another core.
We call this _false sharing_, because even though no single piece of data is shared between tasks, different pieces of data allocated on the same cache line still needs to use the cache coherence protocol.
False sharing doesn't cause data races, but it can tank performance.
We can demonstrate it with the code below, which increments every element in an array using multiple tasks in parallel: When `false_share` is benchmarked the first time below, the eight tasks works on interleaved elements of the vector. Since a 64-byte cache line contains 64 single-byte elements, that means all eight tasks will write to the same cache lines at the same time.
In contrast, the second time the function is benchmarked, each task will get its own 64-byte slice of the vector to update such that no cache line is shared between tasks.
"""
# ╔═╡ 9a6d75a0-0f0c-4909-886d-9a2377ef0ee7
begin
function update_bytes(v::Vector{UInt8}, start::Int, step::Int)
chunksize = div(length(v), 8)
for _ in 1:1_000_000
p = start
for _ in 1:chunksize
v[p] += 0x01
p += step
end
end
end
function false_share(starts, inc)
v = zeros(UInt8, 512)
tasks = map(1:8) do i
@spawn update_bytes(v, starts[i], inc)
end
foreach(wait, tasks)
end
end;
# ╔═╡ 05524538-352d-4dff-9f24-5844b46635c8
@btime false_share(1:8, 8)
# ╔═╡ af561fc4-9b1b-405a-90a6-8bc18e93bf3f
@btime false_share(1:64:512, 1)