Alex

Matt Holiday Go Class (YouTube)

Notes from learning the fundamentals of the Go programming language from this amazing tutorial. It is a fantastic video tutorial on YouTube that explains Go concepts from the ground up and offers some great insight into the language design

Variables

var a int
// or
a := 2 // in functions or methods
const(
    a = 1
    b = 3 * 100
    s = "hello"
)

Strings

`string with "quotes"`

Arrays and Slices

Maps

var m map[string]int // nil map (reading any key will return the default value of the map value type)
_m := make(map[string]int) // empty non-nil map
var m = map[string]int {
  "hello": 1
}
p := map[string]int{} // Empty non nil map
a, ok := p["hello"] // Returns 0, false since the key "hello" doesn't exist
p["hello"]++
b, ok := p["hello"] // Returns 1, true

if w, ok := p["the"]; ok {
  // Useful if we want to do something if an entry is / isn't in the map
}

Various Builtin Functions

Reproduced from https://www.youtube.com/watch?v=T0Xymg0_aSU

nil (From https://www.youtube.com/watch?v=ynoY2xz-F8s)

Nil Interfaces

var s fmt.Stringer   // This is a nil interface with no concrete type and no value (nil, nil)

fmt.Println(s == nil)   // Will print true since (nil, nil) == nil

//---

var p *Person // This Person satisfies the person interface

var s fmt.Stringer = p // Now we have (*Person, nil) - a concrete type (*Person) but still no value. This is now no longer equal to nil

//---

func do() error { // This will return the nil pointer wrapped in the error interface (*doError, nil)
  var err *doError
  return err // This is a nil pointer of type *doError
}

fmt.Println(do() == nil) // Will be FALSE because of the above example - (*doError, nil) != nil!!!

// It is good practice to not define or return concrete error variables

Control Statements

if x, err := doSomething(); err != nil {
  return err  
}
for i := range someArr {
  // i is an index here. Remember this - this mistake can happen often. i is the INDEX NOT THE VALUE. 
  // If you want to range over the values you can use the blank identifier like for _, v := range someArray
}

for i, v := range someArr {
  // i is an index, v is the value at that index
  // The value v is COPIED - don't modify. If the values are some large struct, it might be better to use the explicit indexing for loop
}

for k := range someMap {
  // Looping over all keys in a map
}

for k, v := range someMap {
  // Getting the keys and values in the loop
}
for {
  // Infinite loop
}
switch someVal {
  case 0,1,2:
    fmt.Println("Low")
  case 3,4,5:
    // Noop
  default:
    fmt.Println("Other")
}
a := 3

switch {
  case a <= 2:
  case a == 8:
  default:
    // Do something
}

Packages

Imports

Variable Declarations

var a int
var a int = 1
var c = 1       // Type inference
var d = 1.0

// Declaration block for simplicity
var (
  x, y int
  z    float64
  s    string
)

Short Declaration Operator :=

err := doSomething()
err := doSomethingElse() // This is wrong, you can't re-declare err
x, err := doSomethingOther() // This is fine since you are declaring the new var x, and just reassigning err from the original assignment on the skip line above
func do() error {
  var err error

  for {
    n, err := f.Read(buf)

    if err != nil {
      break
    }

    doSomething(buf)
  }

  return err
}

Typing

Structural and Named Typing

Functions (Video 8)

Parameter Passing

Multiple Return Values

Naked Return Values

Defer

func main() {
  f := os.Stdin

  if len(os.Args) > 1 {
    if f, err := os.Open(os.Args[1]); err != nil {
      ...
    }
    defer f.close()
  }

  // At this point we can do something with the file and only if it is a file passed in the params will it be closed at function exit
}
func thing() {
  a := 10
  defer fmt.Println(a)
  a = 11
  fmt.Println(a)
  // Will print 11,10
}

Closures (Video 9)

func fib() func() int {
  a, b := 0, 1

  return func() int {
    a, b = b, a+b
    return b 
  }
}

func main() {
  f := fib()

  for x := f(); x < 100; x = f() {
    fmt.Println(x) // Prints fibonacci numbers less than 100
  }
}

More on Slices (Video 10)

// The following shows some different slices, with information on them given below

var s []int
t := []int{}
u := make([]int, 5)
v := make([]int, 0, 5)
w := []int{1,2,3,4,5}

The Slice Operator

The Slice Capacity Issue

Array and Slice APIs From Here

s := make([]int, 5)

// This is basically the internal implementation of slice growing that Go uses when appending to a slice that has reached it's max capacity
t := make([]int, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

func filter(s []int, fn func(int) bool) {
  var res []int // == nil
  for _, v := range s {
    if fn(v) {
      res = append(res, v)
    } 
  }

  return res
}

Structs and JSON (Video 12)

type Employee struct {
  Name string
  Number int
  Boss *Employyee
  Hired time.Time
}

Maps of Structs

Structure & Name Compatibility of Structs

type thing1 struct {
	field int
}
type thing2 struct {
	field int
}
func main() {
	a := thing1{field: 1}
	b := thing2{field: 1}
	a = thing1(b) // Valid
}

JSON with Structs

type Response struct {
  Data string `json:"data"` // Only exported fields are included in a marshalled JSON string
  Status int `json:"status"`
}

func main() {
  // Serializing
  r := Response{"Some data", 200}
  j, _ := json.Marshal(r)

  // j will be []byte containing "{"data":"Some data","status":200}"

  // Deserializing
  var r2 Response
  _ = json.Unmarshal(j, &r2)
}

Reference and Value Semantics (Video 14)

More on Copying

for i, thing := range things {
  // thing is always a copy - mutating it doesn't mutate the thing in things
}

// You have to use an index if you want to mutate the element
for i := range things {
  things[i].field = value
}
func update(things []thing) []thing {
  things = append(things, x) // Copy
  return things
}

Stack Usage and Escaping

HTTP and Networking in Go (Video 15)

type Handler interface {
  ServeHTTP(http.ResponseWriter, *http.Request)
}
type HandlerFunc func(ResponseWriter, *Request)

// This is a method declaration on a function type
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
  f(w, r)
}

// Then we can define a function that conforms to that interface without 
// requiring explicit implementation of ServeHTTPz§
func handler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Hello, world")
}
var form = `
<h1>Todo #</h1>
<div></div>

OOP Concepts in Go (Videos 17-20)

An Overview

Methods and Interfaces

“An interface, which is something that can do something. Including the empty interface, which is something that can do nothing, which is everything, because everything can do nothing, or at least nothing.” - Brad Fitzpatrick

type Stringer interface {
  String() string
}
type ReadWriter interface {
  Reader
  Writer
}

Interface Declarations

type Bigger struct {
  otherpackage.Big // Struct composition to be explored later
}

func (b Bigger) SomeMethod() {

}

Composition in Go

type Host struct {
  Hostname string
  Port int
}

type SimpleURI struct {
  Host
  Scheme string
  Path string
}

func main() {
  s := SimpleURI{
		Host:   other.Host{Hostname: "google.com", Port: 8080},
		Scheme: "https",
		Path:   "/search",
	}

  fmt.Println(s.Hostname, s.Scheme) // See how the Host has been promoted
}
type Thing struct {
	Field string
}

func (t *Thing) bruh() {
	fmt.Println(t.Field)
}

// Would also be valid with a value receiver method
// func (t Thing) bruh() {
// 	fmt.Println(t.Field)
// }

type Thing2 struct {
	*Thing
	Field2 string
}

func main() {
	t := Thing2{&Thing{"Hello"}, "world"}
	t.bruh() // Method call here is valid
}

Composition with Sorting Example

type Interface interface {
  // The length of the collection
  Len() int
  // Says whether the element at index i is less than the element at index j
  Less(i, j int) bool
  // Swaps the element at index i with the element at index j in the collection
  Swap(i, j int)
}
type Component struct {
  Name string
  Weight int
}

type Components []Component

func (c Components) Len() int { return len(c) }
func (c Components) Swap() { c[i], c[j] = c[j], c[i] }
func (c Components) Less(i, j int) {
  // LT rather than the less than symbol because Jekyll
  return c[i].Weight LT c[j].Weight
}
type ByName struct{ Components }
func (bn ByName) Less(i, j int) bool {
  return bn.Components[i].Name LT bn.Components[j].Name
}

type ByWeight struct{ Components }
func (bw ByWeight) Less(i,j int) bool {
  return bn.Components[i].Weight LT bn.Components[j].Weight
}
type reverse struct {
  Interface // It just embeds sort.Interface
}

func (r reverse) Less(i, j int) bool {
  return r.Interface.Less(j, i) // Note swapped arguments for reverse sorting
}

func Reverse(data Interface) Interface {
  return &reverse{data}
}

Making Nil Useful

// The nil / zero value of this struct is ready to use since a nil slice can be appended to
type StringStack struct {
  data []string
}

func (s *StringStack) Push(x string) {
  s.data = append(s.data, x)
}

func (s *StringStack) Pop() string {
  l := len(s.data)

  if l == 0 {
    panic("pop from empty stack")
  }

  t := s.data[l-1]
  s.data = s.data[:l-1]
  return t
}
type IntList struct {
  Value int
  Tail *IntList
}

func (list *IntList) Sum() int {
  if list == nil {
    return 0
  }
  
  return list.Value + list.Tail.Sum()
}

Exploring Value / Pointer Method Semantics

type Thing struct{}

func (t Thing) ValMethod() {}
func (t *Thing) PointerMethod() {}

type IVal interface { ValMethod() }
type IPtr interface { PointerMethod() }

func main() {
  var t Thing

  var iVal IVal
  var iPtr IPtr

  iVal = t  // Valid
  iVal = &t // Valid

  iPtr = t  // Not valid, since the value t doesn't have the pointer method PointerMethod in it's method set
  iPtr = &t // Valid
}

More on Interfaces (Video 20)

var r io.Reader // nil interface here
var b *bytes.Buffer // nil value here

r = b // at this point r is no longer nil itself, but it has a nil pointer to a buffer

The Error Interface

type error interface {
  func Error() string
}
type someErr struct {
  err error
  someField string
}

func (e someErr) Error() string {
  return "this is some error"
}

func someFunc(a int) *someErr { // We should NEVER return a concrete error type
  return nil
}

func main() {
  var err error = someFunc(123456)

  if err != nil {
    // Even though we logically didn't want to throw an error, returning a concrete error type 
    // meant that the err variable was initialised and looks like (*someErr, nil) which in the
    // semantics of interfaces ISN'T NIL
    fmt.Println("Oops")
  } else {
    // If we'd done err := someFunc(123456), the above check would have worked although again we 
    // should never return a concrete error implementation from a function
  }
}

More on Pointer vs Value Receivers from Matt Holiday Vid

Interfaces in Practice

  1. Let consumers define the interfaces; what minimal set of behaviours do they require
    • This lets the caller have maximum freedom to pass in whatever it wants so long as the interface contract is adhered to
  2. Reuse standard interfaces wherever possible
  3. Keep interface declarations as small as possible - bigger interfaces have weaker abstractions
    • The Unix file API is simple for a reason
  4. Compose one method interfaces into larger interfaces (if needed)
  5. Avoid coupling interfaces to particular types or implementations; interfaces should define abstract behaviour
  6. Accept interfaces but return concrete types

Be liberal in what you accept, be conservative in what you return

Empty Interfaces

Revisiting Understanding nil

Zero Values

Nil

Understanding the Different Types of nil

Pointers

Slices

Maps, Channels and Functions

Nil Interfaces

func bad1() error {
  var err *someConcreteError
  return err // We are returning (*someConcreteError, nil) which !=nil
}

func bad2() *someConcreteError {
  // We are returning a concrete pointer to an error which will pass ==nil, however
  // it is very bad practice because the second you wrap this pointer in the error
  // interface you will have the same problem as above
  return nil
}

How is Nil Useful

Nil Pointers

Nil Slices

Nil Slices

Nil channels example

Function Currying

func Add(a, b int) {
  return a+b
}

func main() {
  var addTo5 func(int) int = func (a int) int {
    return Add(5, a)
  }
}

Method Values

func (p Point) Distance(q Point) float64 {
  return math.Hypot(q.X-p.X, q.Y-p.Y)
}

func main() {
  p := Point{1,2}
  q := Point{4,6}

  distanceFromP := p.Distance // Here we close over the receiver value p, returning a curried function
}

Notes From Homework #4

type dollars float32

func (d dollars) String() string {
	return fmt.Sprintf("$%.2f", d)
}

type database map[string]dollars

func (db database) list(w http.ResponseWriter, req *http.Request) {
	for item, price := range db {
		fmt.Fprintf(w, "%s: %s\n", item, price)
	}
}

func main() {
	db := database{
		"shoes": 50,
		"socks": 5,
	}
	http.HandleFunc("/list", db.list)
	log.Fatal(http.ListenAndServe("localhost:8080", nil))
}

Concurrency (Finally!) (Video 22)

Defining Concurrency

Reproduced from https://www.youtube.com/watch?v=A3R-4ZYBqvE
{1,2a,2b,3a,3b,4}
{1,2a,3a,2b,3b,4}
{1,2a,3a,3b,2b,4}
{1,3a,3b,2a,2b,4}
{1,3a,2a,2b,3b,4}
{1,3a,2a,3b,2b,4}

Concurrency vs Parallelism

Race Conditions

Concurrency In Go (Video 23)

Channels Overview

Goroutines Overview

http.HandlerFunc Channel Pattern

type intCh chan int

func (ch intCh) handler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Received %d from channel", <-ch)
}

Prime Sieve Example

Reproduced from https://www.youtube.com/watch?v=zJd7Dvg3XCk
// generates numbers up to the given limit and writes them to a channel, closing the channel when finished
func generate(limit int, ch chan<- int) {
	defer close(ch)
	for i := 2; i < limit; i++ {
		ch <- i
	}
}

// receives numbers from a channel and filters for only those not divisible by the given 
// divisor, writing to a destination channel and closing the destination when the src is closed
func filter(src <-chan int, dst chan<- int, divisor int) {
	defer close(dst)
	for i := range src { // Will block until a value is added to src, and break when src is closed
		if i%divisor != 0 {
			dst <- i
		}
	}
}

// prime sieving function
func sieve(limit int) {
	ch := make(chan int)
	go generate(limit, ch) // kicks off generator

	for {
		prime, ok := <-ch
		if !ok {
			break // we are done
		}

		// makes a new filter for the prime that was just seen, then adds it to the chain of running filters
		newFilterChan := make(chan int)
		go filter(ch, newFilterChan, prime)
		ch = newFilterChan

		fmt.Print(prime, " ")
	}
}

func main() {
	sieve(1000)
}

Select (Video 24)

func main() {
  chans := []chan int{
    make(chan int),
    make(chan int)
  }

  for i := range chans {
    go func(i int, ch chan<- int) {
      for {
        time.Sleep(time.Duration(i)*time.Second)
        ch<- i
      }
    }(i+1, chans[i])
  }

  for i := 0; i < 12; i ++ {
    // Select allows us to listen to both channels at the same time, and whichever
    // one is ready first will be read
    select {
    case m0 := <-chans[0]:
      fmt.Println("received", m0)
    case m1 := <-chans[1]:
      fmt.Println("received", m1)
    }
  }
}

Default Case

func sendOrDrop(data []byte) {
  select {
  case ch <- data;
    // sent ok; do nothing
  default:
    log.Printf("overflow, dropped %d bytes", len(data))
  }
}

Context (Video 25)

The Context Tree

ctx := context.Background()
ctx = context.WithValue(ctx, "traceId", "abc123")
ctx, cancel := context.WithTimeout(ctx, 3 * time.Second)
defer cancel() // it is common to defer cancel

req, _ := http.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)

Context With Values

type contextKey int

// Make sure the keys are exported (but not the type itself), then clients have a single source of truth for requesting context values without the risk of collision
const (
  TraceIdContextKey contextKey = iota
  StartTimeContextKey contextKey
  AuthContextKey contextKey
)
func AddTrace(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if traceID := r.Header.Get("X-Trace-Context"); traceID != "" {
      ctx = context.WithValue(ctx, TraceIdContextKey, traceID)
    }

    next.ServeHTTP(w, r.WithContext(ctx))
  })
}

func LogWithContext(ctx context.Context, f string, args ...any) {
   // reflection is required because the context values "map" can contain any. We need
   // to downcast the any to a string (this two argument cast will return ok=true if
   // the conversion was a success). More on reflection later ;)
  traceID, ok := ctx.Value(TraceIdContextKey).(string)

  // adding the trace ID to the log message if it is in the context
  if ok && traceID != "" {
    f = traceID + ": " + f
  }

  log.Printf(f, args...)
}

More on Channels (Video 26)

func get(url string, ch chan<- result) { }
func collect(ch <-chan result) map[string]int { }

Closed Channels

Nil Channels

Reproduced from https://www.youtube.com/watch?v=fCkxKGd6CVQ

Rendezvous Model

Reproduced from https://www.youtube.com/watch?v=fCkxKGd6CVQ

Buffering

Important Note

type T struct {
  i byte
  b bool
}

func send(i int, ch chan<- *T) {
  t := &T{i: byte(i)}
  ch<- t
  t.b = true // NEVER DO THIS
}

func main() {
  vs := make([]T, 5)
  ch := make(chan *T)
  for i := range vs {
    go send(i, ch)
  }

  time.Sleep(1*time.Second)

  // This quick copy will read and copy the values written into the channel by
  // the 5 running goroutines. But there is a race condition so the value of t.b
  // for all the values is false since it is likely (but not guaranteed) that this
  // read and copy will finish before the t.b is updated in the goroutine. If the
  // channel was buffered, it would be likely (but again not a guarantee) that 
  // the value is true for all. The time.Sleep() will almost guarantee that this
  // is the case but again this is a race condition so it should never be relied upon
  for i := range vs {
    vs[i] = *<-ch
  }

  for _,v :+ range vs {
    fmt.Println(v)
  }
}

Why Buffering

Concurrent File Processing Example (Video 27)

type pair struct {
  hash, path string
}
type fileList []string
type results map[string]fileList

// calculate the hash of a specific file path, returning a pair of
// (hash, path)
func hashFile(path string) pair {
  file, err := os.Open(path)
  if err != nil {
    log.Fatal(err)
  }
  defer file.Close()

  hash := md5.New()
  if _, err := io.Copy(hash, file); err != nil {
    log.Fatal(err)
  }

  return pair{fmt.Sprintf("%x", hash.Sum(nil)), path}
}

// this is a sequential implementation, could be quite slow on a large directory
func walk(dir string) (results, error) {
  hashes := make(results)
  err := filepath.Walk(dir, func(path string, fi os.FileInfo, err error) error {
    if fi.Mode().IsRegular() && fi.Size() > 0 {
      h := hashFile(path)
      hashes[h.hash] = append(hashes[h.hash], h.path) // add the new file path to it's corresponding hash entry in the map
    }
    return nil
  })
  return hashes, er r
}

Concurrent Approaches

Worker Pool

Reproduced from https://www.youtube.com/watch?v=SPD7TykYy5w
func collector(pairs <-chan pair, result chan<- results) {
  hashes := make(results)

  // loop will only stop when the channel closes
  for p := range pairs {
    hashes[p.hash] = append(hashes[p.hash], p.path)
  }
  result <- hashes
}

func worker(paths <-chan string, pairs chan<- pair, done chan<- bool) {
  // process files until the paths channel is closed
  for path := range paths {
    pairs <- hashFile(path)
  }
  done <- true
}

func main() {
  numWorkers := 2 * runtime.GOMAXPROCS(0)

  // the first model has unbuffered channels
  paths := make(chan string)
  pairs := make(chan pair)
  done := make(chan bool)
  result := make(chan results)

  for i := 0; i < numWorkers; i++ {
    go processFiles(paths, pairs, done)
  }

  go collectHashes(pairs, result)

  err := filePath.Walk(fir, func(path string, fi os.FileInfo, err error) error {
    if fi.Mode().IsRegular() && fi.Size() > 0 {
      paths <- p
    }
    return nil
  })

  if err != nil {
    log.Fatal(err)
  }

  // so the workers stop
  close(paths)
  
  for i := 0; i < numWorkers; i++ {
    // we then read from the done channel until all workers are done
    <-done
  }

  // after all the workers are done we can close the pairs channel
  close(pairs)

  // finally we can read the hashes from the result channel
  hashes := <-result

  fmt.Println(hashes)
}

Goroutine for Each Directory in the Tree Approach

func searchTree(dir string, paths chan<- string, wg *sync.WaitGroup) error {
  defer wg.Done()

  visit := func(p string, fi os.FileInfo, err error) error {
    if err != nil && err != os.ErrNotExist {
      return err
    }

    // ignore dir itself to avoid an infinite loop
    if fi.Mode().IsDir() && p != dir {
      wg.Add(1)
      go searchTree(p, paths, wg) // we recursively search the tree in new goroutines to speed up listing
      return filepath.SkipDir
    }

    if fi.Mode().IsRegular() && fi.Size() > 0 {
      paths <- p
    }

    return nil
  }

  return filepath.Walk(dir, visit)
}

func run(dir string) results {
  workers := 2 * runtime.GOMAXPROCS(0)
  paths := make(chan string)
  pairs := make(chan pair)
  done := make(chan bool)
  result := make(chan results)
  wg := new(sync.WaitGroup)

  for i := 0; i < workers; i++ {
    go processFiles(paths, pairs, done)
  }

  go collectHashes(pairs, result)

  // multi-threaded walk of the directory tree
  wg.Add(1)

  err := searchTree(dir, paths, wg)

  if err != nil {
    log.Fatal(err)
  }

  // wg.Wait() will block until all the directory listing work is done
  wg.Wait()
  close(paths)

  for i := 0; i < workers; i++ {
    <-done
  }

  close(pairs)
  return <-result
}

No Workers - Just Goroutine for Each File and Directory

Reproduced from https://www.youtube.com/watch?v=SPD7TykYy5w
func processFile(path string, pairs chan<- pair, wg *sync.WaitGroup, limits chan bool) {
  defer wg.Done()

  // writing to limits will block until another processFile goroutine finishes
  limits <- true

  // this is the point that the goroutine is finished (defered). reading from the channel will free up a slot for another goroutine
  defer func() {
    <-limits
  }()

  pairs <- hashFile(path)
}

func collectHashes(pairs <-chan pair, result chan<- results) {
  hashes := make(results)

  for p := range pairs {
    hashes[p.hash] = append(hashes[p.hash], p.path)
  }

  result <- hashes
}

func walkDir(dir string, pairs chan<- pair, wg *sync.WaitGroup, limits chan bool) error {
  defer wg.Done()

  visit := func(p string, fi os.FileInfo, err error) error {
    if err != nil && err != os.ErrNotExist {
      return err
    }

    // ignore dir itself to avoid an infinite loop!
    if fi.Mode().IsDir() && p != dir {
      wg.Add(1)
      go walkDir(p, pairs, wg, limits)
      return filepath.SkipDir
    }

    if fi.Mode().IsRegular() && fi.Size() > 0 {
      wg.Add(1)
      go processFile(p, pairs, wg, limits)
    }

    return nil
  }

  // again since this walkDir is also IO bound, we have this functionality to wait on the limits channel
  // until a slot opens
  limits <- true

  defer func() {
    <-limits
  }()

  return filepath.Walk(dir, visit)
}

func run(dir string) results {
  workers := 2 * runtime.GOMAXPROCS(0)
  limits := make(chan bool, workers)
  pairs := make(chan pair)
  result := make(chan results)
  wg := new(sync.WaitGroup)

  // we need another goroutine so we don't block here
  go collectHashes(pairs, result)

  // multi-threaded walk of the directory tree; we need a
  // waitGroup because we don't know how many to wait for
  wg.Add(1)

  err := walkDir(dir, pairs, wg, limits)

  if err != nil {
    log.Fatal(err)
  }

  // we must close the paths channel so the workers stop
  wg.Wait()

  // by closing pairs we signal that all the hashes
  // have been collected; we have to do it here AFTER
  // all the workers are done
  close(pairs)

  return <-result
}

func main() {
  if len(os.Args) < 2 {
    log.Fatal("Missing parameter, provide dir name!")
  }

  if hashes := run(os.Args[1]); hashes != nil {
    for hash, files := range hashes {
      if len(files) > 1 {
        // we will use just 7 chars like git
        fmt.Println(hash[len(hash)-7:], len(files))

        for _, file := range files {
          fmt.Println("  ", file)
        }
      }
    }
  }
}

Amdahl’s Law

Reproduced from https://www.youtube.com/watch?v=SPD7TykYy5w

Conventional Synchronisation (Video 28)