© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2022
R. WienerGeneric Data Structures and Algorithms in Gohttps://doi.org/10.1007/978-1-4842-8191-8_1

1. A Tour of Generics and Concurrency in Go

Richard Wiener1  
(1)
Colorado Springs, CO, USA
 

This chapter introduces the syntax and semantics of generics in Go. Many coding examples are presented that illustrate this new and powerful feature of Go. This sets the stage for the continued use of generics throughout the book.

Concurrency in Go is also reviewed in this chapter. Many coding examples are presented along with benchmarks that contrast the performance of algorithms with and without concurrency. This also sets the stage for the continued use of concurrency throughout the book.

1.1 Brief History and Description of Go

Go is a relatively new programming language released in late 2009 and developed at Google by Robert Griesemer (a Swiss computer scientist who helped create Google’s V8 JavaScript engine), Rob Pike (a Canadian computer scientist and part of the Unix team at Bell Labs and creator of the Limbo programming language), and Ken Thompson (creator of the Unix operating system and the B programming language).

The Go programming language is sometimes called Golang. Why? The domain “go.org” wasn’t available at the time the language was released, so golang.org (a mix of Go and language) was born. The official name of the language is Go, but the Twitter tag is #golang. Go figure!

One of the major goals in creating Go was to produce an easily readable, strong, and statically typed language with garbage collection and fast compilation and execution speed particularly suited for concurrent applications.

The goroutine is a lightweight process that requires less memory overhead than a normal thread seen in other languages such as Java and C#. A concurrent Go program may spawn thousands of goroutines running on a much smaller number of threads. The channel construct (to be explained later in this chapter) allows information to be passed into and taken out of goroutines and is used to synchronize these concurrent lightweight processes. Although parallel processing is not the primary objective of goroutines, they can be used to approximate this on a shared memory multiprocessor computer.

Go is a platform-independent language that runs on various Unix platforms including MacOS and also runs on MS Windows. Go applications compile to a binary executable so they can be distributed to a customer without having to package an interpreter and runtime libraries as is the case with Python and other interpreted languages.

Go, like many recent languages, is a public open source project. There are a bevy of free tools that are downloadable. New packages are constantly being released, so much of the power of the language resides outside the language in the plethora of high-quality packages available to the programmer. In this sense, Go is like Python.

Among the tools that are available are high-quality editors, debuggers, and IDEs. Go requires a prescribed format, so the gofmt tool is often integrated into various code editors. Having a standard code format provides a huge advantage to Go programming teams as well as solo programmers inspecting the code written by others.

So what is missing in Go? What is its downside? Up until the most recent and perhaps most important new release, Version 1.18, Go lacked genericity. With this new release of Go, this major shortcoming is gone.

Now one can build an algorithm or data structure that does not have to be modified every time the underlying information to be stored changes. Data structure and algorithm implementations can focus on the core logic needed to manipulate the information. A new syntax associated with generics allows a programmer to precisely describe the requirements that data must satisfy to be stored in a particular data structure. This furthers a programmer’s ability to have a program specify its intent in the code itself. The use of constrained and unconstrained generic parameters is introduced and illustrated in the next section.

1.2 Introducing Generic Parameters

In this section, we present a series of examples that introduce and illustrate the use of generic-type parameters, both unconstrained and constrained.

In the first several code listings, we present a set of related problems of adding a new student to an existing slice of students. First, we add just the name of the student to our existing slice. Next, we add the student’s ID number to a slice containing ID numbers. Next, we add a struct containing name, ID, and age to an existing slice of structs. Then finally, we bring generics on stage and show the simplification that is achievable using a generic-type parameter.

Adding a New Student by Name

Consider the simple Go application given in Listing 1-1.
package main
import(
    "fmt"
)
func addStudent(students []string, student string) []string {
    return append(students, student)
}
func main() {
    students := []string{} // Empty slice
    result := addStudent(students, "Michael")
    result = addStudent(result, "Jennifer")
    result = addStudent(result, "Elaine")
    fmt.Println(result)
}
/*
Output:
[Michael Jennifer Elaine]
*/
Listing 1-1

A slice of students

The function addStudent takes a slice of string representing the current collection of students as the first parameter and a string representing a new student to be added to the collection as the second parameter. The append function is used to add the new student to the existing slice, and that result is returned.

Adding a New Student by ID Number

Suppose we wish to specify the slice of students using their ID number, an int, instead of their name, a string.

We would need to modify Listing 1-1 as shown in Listing 1-2.
package main
import(
    "fmt"
)
func addStudent(students []string, student string) []string {
    return append(students, student)
}
func addStudentID(students []int, student int) []int {
    return append(students, student)
}
func main() {
    students := []string{} // Empty slice
    result := addStudent(students, "Michael")
    result = addStudent(result, "Jennifer")
    result = addStudent(result, "Elaine")
    fmt.Println(result)
    students1 := []int{} // Empty slice
    result1 := addStudentID(students1, 155)
    result1 = addStudentID(result1, 112)
    result1 = addStudentID(result1, 120)
    fmt.Println(result1)
}
/* Output
[Michael Jennifer Elaine]
[155 112 120]
*/
Listing 1-2

Adding student IDs

The logic in function addStudentID is essentially the same as in function addStudent. Only the base type of the slice is changed from string to int.

Adding a New Student by Student Struct

And to take this one step further, suppose we define a Student type as
type Student struct {
    Name string
    ID int
    age float64
}
and we modify Listing 1-2 as shown in Listing 1-3.
package main
import(
    "fmt"
)
type Student struct {
    Name string
    ID int
    age float64
}
func addStudent(students []string, student string) []string {
    return append(students, student)
}
func addStudentID(students []int, student int)  []int {
    return append(students, student)
}
func addStudentStruct(students []Student, student Student) []Student {
    return append(students, student)
}
func main() {
    students := []string{} // Empty slice
    result := addStudent(students, "Michael")
    result = addStudent(result, "Jennifer")
    result = addStudent(result, "Elaine")
    fmt.Println(result)
    students1 := []int{} // Empty slice
    result1 := addStudentID(students1, 155)
    result1 = addStudentID(result1, 112)
    result1 = addStudentID(result1, 120)
    fmt.Println(result1)
    students2 := []Student{} // Empty slice
    result2 := addStudentStruct(students2, Student{"John", 213, 17.5} )
    result2 = addStudentStruct(result2,  Student{"James", 111, 18.75} )
    result2 = addStudentStruct(result2,  Student{"Marsha", 110, 16.25} )
    fmt.Println(result2)
}
/* Output
[Michael Jennifer Elaine]
[155 112 120]
[{John 213 17.5} {James 111 18.75} {Marsha 110 16.25}]
*/
Listing 1-3

Adding Student type to the mix

Having to add a new function each time we wish to add a new underlying data type to our various student collections is tedious and a major downside to earlier versions of Go.

Introducing Generics

Enter Go, Version 1.18, that introduces support for generics.

A generic solution to this problem is presented in Listing 1-4.
package main
import (
    "fmt"
)
type Stringer = interface {
    String() string
}
type Integer int
func (i Integer) String() string {
    return fmt.Sprintf("%d", i)
}
type String string
func (s String) String() string {
    return string(s)
}
type Student struct {
    Name string
    ID int
    Age float64
}
func (s Student) String() string {
    return fmt.Sprintf("%s %d %0.2f", s.Name, s.ID, s.Age)
}
func addStudent[T Stringer](students []T, student T) []T {
    return append(students, student)
}
func main() {
    students := []String{}
    result := addStudent[String](students, "Michael")
    result = addStudent[String](result, "Jennifer")
    result = addStudent[String](result, "Elaine")
    fmt.Println(result)
    students1 := []Integer{}
    result1 := addStudent[Integer](students1, 45)
    result1 = addStudent[Integer](result1, 64)
    result1 = addStudent[Integer](result1, 78)
    fmt.Println(result1)
    students2 := []Student{}
    result2 := addStudent[Student](students2, Student{"John", 213, 17.5} )
    result2 = addStudent[Student](result2, Student{"James", 111, 18.75} )
    result2 = addStudent(result2,  Student{"Marsha", 110, 16.25} )
    fmt.Println(result2)
}
/* Output
[Michael Jennifer Elaine]
[45 64 78]
[John 213 17.50 James 111 18.75 Marsha 110 16.25]
*/
Listing 1-4

Generic solution to problem

Stringer Type

A type Stringer is defined as an interface containing a single method signature:

String() string.

Any entity that implements this type by having a well-defined String() definition can be converted to a string for output purposes. Since we wish to be able to output our various student collections (slices), we constrain the data type, T, in the signature of our generic addStudent function to be of type Stringer.

Constrained Generic Type

The generic signature of our single addStudent function becomes

func addStudent[T Stringer](students []T, student T) [ ]T

Types Integer, String, and Student are defined along with their definitions of String() so that we can use generic function addStudent using each of these Stringer types.

Implementing an Interface

In Go, one implements an interface implicitly by implementing the function(s) specified in the interface definition. In this case, any type that implements a String() function can be considered to be of type Stringer.

Instantiating a Generic Type

In main, after declaring students to be an empty slice of String (not a slice of string), we invoke addStudent[String](students, “Michael”).

The generic parameter T constrained to be of type Stringer is replaced by the actual instantiated type String which we know is of type Stringer because it implements the Stringer interface (which has a concrete definition of String()).

We next use addStudent with Integer used as the Stringer type. And finally, we use addStudent with Student as the Stringer type.

Unconstrained Generic Type any

The Go compiler can do type inferencing if we replace the constrained generic type [T Stringer] with the unconstrained type any.

Listing 1-5 presents a simpler, less verbose, generic implementation of addStudent along with a driver function main that exercises this generic function.
package main
import (
    "fmt"
)
type Student struct {
    Name string
    ID int
    Age float64
}
func addStudent[T any](students []T, student T) []T {
    return append(students, student)
}
func main() {
    students := []string{}
    result := addStudent[string](students, "Michael")
    result = addStudent[string](result, "Jennifer")
    result = addStudent[string](result, "Elaine")
    fmt.Println(result)
    students1 := []int{}
    result1 := addStudent[int](students1, 45)
    result1 = addStudent[int](result1, 64)
    result1 = addStudent[int](result1, 78)
    fmt.Println(result1)
    students2 := []Student{}
    result2 := addStudent[Student](students2, Student{"John", 213, 17.5} )
    result2 = addStudent[Student](result2, Student{"James", 111, 18.75} )
    result2 = addStudent(result2,  Student{"Marsha", 110, 16.25} )
    fmt.Println(result2)
}
/* Output
[Michael Jennifer Elaine]
[45 64 78]
[John 213 17.50 James 111 18.75 Marsha 110 16.25]
*/
Listing 1-5

Simpler generic function addStudent

Using type inferences, the compiler uses the default conversions of string, int, and Student to allow program output by converting each of these types to string.

Benefits of Generics

In Listing 1-5, the benefits of generics are evident. The simple algorithm for appending a new student (second parameter) to the existing collection of students is expressed independently of the type being used to represent a student.

Suppose we wish to sort each collection of students prior to outputting the collection. We can do so with the sort package discussed in the next section.

Using Go’s Sort Package

The Sort function in Go’s sort package, sort.Sort, requires that the type in the slice being sorted must implement three methods:
  1. 1.

    Len

     
  2. 2.

    Less

     
  3. 3.

    Swap

     

We show how a generic collection implemented as a slice can be sorted.

We define OrderedSlice as follows and provide the required group of Len, Less, and Swap.
// Group of functions that ensure that an OrderedSlice can be sorted
type OrderedSlice[T Ordered] []T // T must implement < and >
func (s OrderedSlice[T]) Len() int {
    return len(s)
}
func (s OrderedSlice[T]) Less(i, j int) bool {
    return s[i] < s[j]
}
func (s OrderedSlice[T]) Swap(i, j int) {
    s[i], s[j] = s[j], s[i]
}
// end group for OrderedSice

Sort Type

We introduce another type, SortType, along with the required group of Len, Less, and Swap.
// Group of functions that ensure that SortType can be sorted
type SortType[T any] struct {
    slice []T
    compare func(T, T) bool
}
func (s SortType[T]) Len() int {
    return len(s.slice)
}
func (s SortType[T]) Less(i, j int) bool {
    return s.compare(s.slice[i], s.slice[j])
}
func (s SortType[T]) Swap(i, j int) {
    s.slice[i], s.slice[j] = s.slice[j], s.slice[i]
}
// end of group for SortType
Finally, we define a function, PerformSort, that uses SortType as follows:
func PerformSort[T any](slice []T, compare func(T, T) bool) {
    sort.Sort(SortType[T]{slice, compare})
}

The user of PerformSort must supply a function for comparing two elements of type T.

Listing 1-6 integrates this functionality into the code that implements the generic addStudent function to allow us to use the imported sort package and its function Sort.
package main
import (
    "fmt"
    "sort"
)
type Ordered interface {
           ~int | ~float64 | ~string
}
type Student struct {
    Name string
    ID int
    Age float64
}
func addStudent[T any](students []T, student T) []T {
    return append(students, student)
}
// Group of functions that ensure that an OrderedSlice can be sorted
type OrderedSlice[T Ordered] []T // T must implement < and >
func (s OrderedSlice[T]) Len() int {
    return len(s)
}
func (s OrderedSlice[T]) Less(i, j int) bool {
    return s[i] < s[j]
}
func (s OrderedSlice[T]) Swap(i, j int) {
    s[i], s[j] = s[j], s[i]
}
// end group for OrderedSice
// Group of functions that ensure that SortType can be sorted
type SortType[T any] struct {
    slice []T
    compare func(T, T) bool
}
func (s SortType[T]) Len() int {
    return len(s.slice)
}
func (s SortType[T]) Less(i, j int) bool {
    return s.compare(s.slice[i], s.slice[j])
}
func (s SortType[T]) Swap(i, j int) {
    s.slice[i], s.slice[j] = s.slice[j], s.slice[i]
}
// end of group for SortType
func PerformSort[T any](slice []T, compare func(T, T) bool) {
    sort.Sort(SortType[T]{slice, compare})
}
func main() {
    students := []string{}
    result := addStudent[string](students, "Michael")
    result = addStudent[string](result, "Jennifer")
    result = addStudent[string](result, "Elaine")
    sort.Sort(OrderedSlice[string](result))
    fmt.Println(result)
    students1 := []int{}
    result1 := addStudent[int](students1, 78)
    result1 = addStudent[int](result1, 64)
    result1 = addStudent[int](result1, 45)
    sort.Sort(OrderedSlice[int](result1))
    fmt.Println(result1)
    students2 := []Student{}
    result2 := addStudent[Student](students2, Student{"John", 213, 17.5} )
    result2 = addStudent[Student](result2, Student{"James", 111, 18.75} )
    result2 = addStudent(result2,  Student{"Marsha", 110, 16.25} )
    PerformSort[Student](result2, func(s1, s2 Student) bool {
        return s1.Age < s2.Age // comparing two Student values
    })
    fmt.Println(result2)
}
/* Output
[Elaine Jennifer Michael]
[45 64 78]
[{Marsha 110 16.25} {John 213 17.5} {James 111 18.75}]
*/
Listing 1-6

Building and sorting slices of students

Map Functions

Map functions in Go are commonplace and perform a transformation in a slice to produce a new slice with the transformed results. Consider this example:
func MyMap(input []int, f func(int) int) []int {
    result := make([]int, len(input))
    for index, value := range input {
        result[index] = f(value)
    }
    return result
}
func main() {
    slice := []int{1, 5, 2, 7, 4}
    result := MyMap(slice, func(i int) int {
        return i * i
    })
    fmt.Println(result)
}
/* Output
[1 25 4 49 16]
*/

The MyMap function produces an output slice containing the square of the integers contained in the input slice. After declaring result to be a slice of len(slice) integers, it iterates over the range of values in input, transforming each value based on the function f passed in to MyMap.

Making MyMap Generic

MyMap can be made generic as follows:
func GenericMap[T1, T2 any](input []T1, f func(T1) T2) []T2 {
  result := make([]T2, len(input))
  for index, value := range input {
    result[index] = f(value)
  }
  return result
}

Function GenericMap takes two generic parameters, T1 and T2. Using the function f that is passed in, it transforms the data in the input slice to type T2. Here, T1 and T2 are not constrained. They are of type any.

Filter Functions

Filter functions in Go are also commonplace and perform a filtering operation on an input slice based on a function passed in. Consider this example:
func MyFilter(input []float64, f func(float64) bool) []float64 {
    var result []float64
    for _, value := range input {
        if f(value) == true {
            result = append(result, value)
        }
    }
    return result
}
func main() {
    input := []float64{17.3, 11.1, 9.9, 4.3, 12.6}
    res := MyFilter(input, func(num float64) bool {
        if num <= 10.0 {
            return true
        }
        return false
    })
    fmt.Println(res)
}
/* Output
[9.9 4.3]
*/

Here, any value in the input slice that is less than or equal to 10.0 is retained, and all other values are filtered out.

Making MyFilter Generic

MyFilter can be made generic as follows:
func GenericFilter[T any](input []T, f func(T) bool) []T {
  var result []T
  for _, val := range input {
    if f(val) {
     result = append(result, val)
    }
  }
  return result
}
In Listing 1-7, we exercise the generic map and filter functions.
package main
import (
    "fmt"
)
func GenericMap[T1, T2 any](input []T1, f func(T1) T2) []T2 {
  result := make([]T2, len(input))
  for index, value := range input {
    result[index] = f(value)
  }
  return result
}
func GenericFilter[T any](input []T, f func(T) bool) []T {
  var result []T
  for _, val := range input {
    if f(val) {
     result = append(result, val)
    }
  }
  return result
}
func main() {
    input := []float64{-5.0, -2.0, 4.0, 8.0}
    result1 := GenericMap[float64, float64](input, func(n float64) float64 {
        return n * n
    })
    fmt.Println(result1)
    greaterThanFive := GenericFilter[int]([]int{4, 6, 5, 2, 20, 1, 7},
        func(i int) bool {
            return i > 5
        })
    fmt.Println(greaterThanFive)
    oddNumbers := GenericFilter[int]([]int{4, 6, 5, 2, 20, 1, 7},
        func(i int) bool {
            return i % 2 == 1
        })
    fmt.Println(oddNumbers)
    lengthGreaterThan3 := GenericFilter[string]([]string{"hello", "or", "the", "maybe"}, func(s string) bool {
                                 return len(s) > 3
                             })
    fmt.Println(lengthGreaterThan3)
}
/* Output
[25 4 16 64]
[6 20 7]
[5 1 7]
[hello maybe]
*/
Listing 1-7

Using generic map and filter functions

We now turn our attention to the use of concurrency.

1.3 Concurrency

Concurrency allows a program to process multiple tasks at the same time (parallel processing where each task is assigned to a separate processor) or what appears to be at the same time where tasks are multiplexed so progress is made on all tasks over time. If the multiplexing is very fast, it appears that the concurrent processes are running at the same time but are run in overlapping periods of time.

In most languages that support concurrent processing, the thread construct is used to support concurrency. There is memory overhead associated with a thread, so the number of threads that can be spawned at the same time is limited.

Goroutine

In developing the Go language, Google introduced a lightweight process called a goroutine that requires less memory overhead than a thread. Their motivation was to be able to serve multiple HTML web pages made from many web browsers at the same time.

Goroutines are functions that run concurrent with other functions. When a regular function is invoked, the code below the function gets executed after the function completes its work. When a goroutine function is invoked, the code directly below it gets executed immediately since the goroutine runs concurrently with code beneath it.

We illustrate this in Listing 1-8.
package main
import (
    "fmt"
    "time"
)
func regularFunction() {
    fmt.Println("Just entered regularFunction()")
    time.Sleep(5 * time.Second)
}
func goroutineFunction() {
    fmt.Println("Just entered goroutineFunction()")
    time.Sleep(10 * time.Second)
    fmt.Println("goroutineFunction finished its work")
}
func main() {
    go goroutineFunction()
    fmt.Println("In main one line below goroutineFunction()")
    regularFunction()
    fmt.Println("In main one line below regularFunction()")
}
/* Output
In main, one line below goroutineFunction()
Just entered regularFunction()
Just entered goroutineFunction()
In main one line below regularFunction()
*/
Listing 1-8

Simple goroutine running concurrent with main

When the goroutineFunction is launched as a goroutine using go goroutineFunction(), it runs concurrently with the main function, which is a goroutine. The first line of output occurs immediately even though the goroutineFunction requires ten seconds to complete its work. When the regularFunction() is invoked next, five seconds elapses before the line of output. “In main, one line below regularFunction()” is emitted. Function main terminates immediately after this output is emitted, which ends the program before the goroutineFunction can complete its work. It gets interrupted and terminates when the program ends.

If we swap the time delays so that the goroutineFunction has a time delay of five seconds and the regularFunction has a time delay of ten seconds, the output becomes
In main one line below goroutineFunction()
Just entered regularFunction()
Just entered goroutineFunction()
goroutineFunction finished its work
In main, one line below regularFunction()

Now the goroutine running concurrently with main completes it work before the regularFunction and before the main goroutine exits.

WaitGroup

Go provides a mechanism for allowing multiple goroutines to all complete their work before main exits while killing off unfinished goroutines.

We introduce the sync package and the WaitGroup construct and illustrate its use in Listing 1-9.
package main
import (
    "fmt"
    "time"
    "math/rand"
    "sync"
)
var wg sync.WaitGroup
func outputStrings() {
    defer wg.Done()
    strings := [5]string{"One", "Two", "Three", "Four", "Five"}
    for i := 0; i < 5; i++ {
        delay := 1 + rand.Intn(3)
        time.Sleep(time.Duration(delay) * time.Second)
        fmt.Println(strings[i])
    }
}
func outputInts() {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        delay := 1 + rand.Intn(3)
        time.Sleep(time.Duration(delay) * time.Second)
        fmt.Println(i)
    }
}
func outputFloats() {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        delay := 1 + rand.Intn(3)
        time.Sleep(time.Duration(delay) * time.Second)
        fmt.Println(float64(i * i) + 0.5)
    }
}
func main() {
    wg.Add(3)
    go outputStrings()
    go outputInts()
    go outputFloats()
    wg.Wait()
}
/* Output
One
0.5
0
1
Two
1.5
2
4.5
3
Three
Four
4
9.5
Five
16.5
*/
Listing 1-9

The sync package and WaitGroup

This program does nothing useful except illustrating the WaitGroup construct and shows three goroutines running concurrently.

A global variable wg of type sync.WaitGroup is declared.

In main, we invoke wg.Add(3). The last line of code in main is wg .Wait(). This causes main to pause until the value in wg is zero. This assures us that all three goroutines complete their work before the program terminates.

In each of the goroutines, the first line of code, defer wg.Done(), causes the value of the global variable wg to be decremented when the goroutine completes its work. When wg reaches a value of zero, the function main is allowed to exit.

The sequence of random numbers generated is the same each time the program is run, but the output sequence varies from run to run. This is because the time multiplexer allocates different chunks of execution time to each concurrent goroutine differently each time the program runs. After the second run of the program, the output is
One
0
0.5
1.5
Two
1
4.5
2
Three
9.5
16.5
Four
3
Five
4

The Channel

We often want to be able to synchronize the sequence of goroutines and have them communicate with each other. We introduce the powerful construct of the channel to accomplish this.

Consider the goroutines in Listing 1-10.
package main
import (
    "fmt"
    "time"
    "sync"
)
var wg sync.WaitGroup
func pingGenerator(c chan string) {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        c <- "ping"
        time.Sleep(time.Second * 1)
    }
}
func output(c chan string) {
    defer wg.Done()
    for {
        value := <- c
        fmt.Println(value)
    }
}
func main() {
    c := make(chan string)
    wg.Add(2)
    go pingGenerator(c)
    go output(c)
   wg.Wait()
}
/* Output
ping
ping
ping
ping
ping
fatal error: all goroutines are asleep - deadlock!
*/
Listing 1-10

Deadlock

The first line of code in main initializes a channel, c. Channels must be initialized with a make statement before they can be used.

As in the previous listing, we create a WaitGroup variable, wg, with the initial value of 2.

We next launch the two goroutines, pingGenerator and output, passing the channel variable c to each.

The pingGenerator goroutine assigns the string “ping” to the channel variable c every second and does this five times. The left arrow from the value “ping” to the variable c represents the assignment of the “ping” value to c.

The channel must be empty for this assignment to be made. In the output goroutine, the assignment to value, using value := <- c, gobbles up the channel variable c as soon as it is assigned in the pingGenerator. This occurs every second. During the time between “ping” assignments from the pingGenerator, the value assignment is blocked. That is execution is halted within the output goroutine until there is information in the channel assigning to value. So the two goroutines are being affected by the channel variable c, common to both.

There is a problem. When the pingGenerator has emitted its five “ping” assignments, each displayed on the console through the output goroutine, it blocks while waiting for a sixth channel assignment. This never occurs. The program crashes with the error message shown earlier. A deadlock has occurred. The program cannot terminate.

Select Statement

We can resolve this problem by modifying the output goroutine and using a select statement.
func output(c chan string) {
       for {
       select {
          case value := <- c:
              fmt.Println(value)
          case <-time.After(3 * time.Second):
              fmt.Println("Program timed out.")
              wg.Done()
           }
     }
}

In a select statement, the case that occurs first gets executed. If two or more cases are ready to execute, the system chooses one at random. Since the channel c gets assigned to value every second (blocks between assignments), the program outputs the five “ping” assignments. Instead of deadlocking as before, the second case gets executed after three seconds from the time the fifth and final “ping” is assigned to value.

Use a quit Channel to Avoid Using WaitGroup

We can use a quit channel to block main from exiting and avoid the use of WaitGroup as shown in Listing 1-11.
package main
import (
    "fmt"
    "time"
)
var quit chan bool
func pingGenerator(c chan string) {
    for i := 0; i < 5; i++ {
        c <- "ping"
        time.Sleep(time.Second * 1)
    }
}
func output(c chan string) {
    for {
       select {
          case value := <- c:
              fmt.Println(value)
          case <-time.After(3 * time.Second):
              fmt.Println("Program timed out.")
              quit <- true
       }
    }
}
func main() {
    quit = make(chan bool)
    c := make(chan string)
    go pingGenerator(c)
    go output(c)
    <- quit
}
/* Output
ping
ping
ping
ping
ping
Program timed out.
*/
Listing 1-11

Using a quit channel instead of WaitGroup

The quit channel is initialized as the first line of code in main. The last line of code in main, <- quit, blocks main from ending until a Boolean value is assigned to quit. This occurs in the second case statement in goroutine output.

This mechanism for controlling the end of the program is simpler and less encumbered than using WaitGroup.

We add the inevitable pongGenerator to this program.

Channel Direction

Channel direction can be added to a goroutine signature as shown in Listing 1-12. An arrow pointing to the chan from the right, as shown in the signatures to pingGenerator and pongGenerator, requires the goroutine to assign to the channel (a generator). An arrow to the left of chan and pointing to the channel variable requires the goroutine to only consume values in the channel.

If an attempt is made to send information to the channel when it is specified as a consumer, or if an attempt is made to access information from the channel in the case that it is specified as a generator, a compiler error will occur.
package main
import (
    "fmt"
    "time"
)
var quit chan bool
func pingGenerator(c chan<- string) {
    // The channel can only be sent to - a generator
    for i := 0; i < 5; i++ {
    c <- "ping"
    }
}
func pongGenerator(c chan<- string) {
    // Information can only be sent to the channel - a generator
    for i := 0; i < 5; i++ {
       c <- "pong"
    }
}
func output(c <- chan string) {
    // Information can only be received from the channel - a consumer
       for {
       time.Sleep(time.Second * 1)
       select {
          case value := <- c:
              fmt.Println(value)
          case <-time.After(3 * time.Second):
              fmt.Println("Program timed out.")
              quit <- true
       }
    }
}
func main() {
    quit = make(chan bool)
    c := make(chan string)
    go pingGenerator(c)
    go pongGenerator(c)
    go output(c)
    <- quit
}
/* Output
ping
pong
ping
pong
ping
pong
ping
pong
ping
pong
Program timed out.
*/
Listing 1-12

Ping pong using direction channels in goroutine signatures

We have moved the one-second time delay into the output goroutine. This allows the ping and pong generators to alternate since each assignment to the channel blocks alternately until the channel is read by the consuming output goroutine.

Race Condition

A pervasive problem using concurrency is race condition. This problem occurs when two or more goroutines modify the same shared data.

A simple example is presented in Listing 1-13.
package main
import (
    "fmt"
    "sync"
)
const (
    number = 1000
)
var countValue int
func main() {
    var wg sync.WaitGroup
    wg.Add(number)
    for i := 0; i < number; i++ {
        go func() {
            countValue++
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Printf(" countValue = %d", countValue)
}
Listing 1-13

Example of race condition

One thousand goroutines are spawned in a for-loop within main. Each goroutine increments countValue exactly once. Therefore, one would expect the output of the program to be 1000.

Each time the program is run, the output is different. This is because of the conflict of multiple goroutines attempting to modify the global changeValue at nearly the same time. There is no error message generated by the system. But the output is incorrect.

Mutex

We can correct the race-condition problem by using a mutex. This locks the global countValue while each goroutine modifies its value and protects this shared data from being corrupted.

Listing 1-14 shows the use of a mutex to remove the race condition.
package main
import (
    "fmt"
    "sync"
)
const number = 1000
var countValue int
var m sync.Mutex
func main() {
    var wg sync.WaitGroup
    wg.Add(number)
    for i := 0; i < number; i++ {
        go func() {
            m.Lock()
            countValue++
            m.Unlock()
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Printf(" countValue = %d", countValue)
}
Listing 1-14

Using mutex to avoid race condition

The code m.Lock() within each goroutine protects the global countValue from modification outside of the goroutine in which it is invoked. No other goroutine can change countValue until the m.Unlock() is invoked.

Program execution is slowed down using the mutex, but the program is protected from the race condition shown in Listing 1-13.

Playing Chess Using Goroutines

Listing 1-15 simulates the sequence of two chess players making moves using goroutines.
// This sample program demonstrates how to use an unbuffered
// channel to simulate a move of chess between two goroutines.
package main
import (
    "fmt"
    "math/rand"
    "time"
)
var quit chan bool
func main() {
    rand.Seed(time.Now().UnixNano())
    move := make(chan int)
    quit = make(chan bool)
    // Launch two players.
    go player("Bobby Fischer", move)
    go player("Boris Spassky", move)
    // Start the move
    move <- 1
    <-quit // Blocks until quit assigned a value
}
// player simulates a person moving in chess.
func player(name string, move chan int) {
    // This function takes data out of the move channel
    // and puts data back into the move channel
    for {
        // Wait for turn to play
        turn := <-move // blocks until move assigned a value (every second)
        // Pick a random number and see if we lose the move
        n := rand.Intn(100)
        if n <= 5 && turn >= 5 {
            fmt.Printf("Player %s was check mated and loses!", name)
            quit <- true
            return
        }
        // Display and then increment the total move count by one.
        fmt.Printf("Player %s has moved. Turn %d. ", name, turn)
        turn++
        // Yield the turn back to the opposing player
        time.Sleep(1 * time.Second)
        move <- turn
    }
}
/*
Player Boris Spassky has moved. Turn 1.
Player Bobby Fischer has moved. Turn 2.
Player Boris Spassky has moved. Turn 3.
Player Bobby Fischer has moved. Turn 4.
Player Boris Spassky has moved. Turn 5.
Player Bobby Fischer has moved. Turn 6.
Player Boris Spassky has moved. Turn 7.
Player Bobby Fischer has moved. Turn 8.
Player Boris Spassky has moved. Turn 9.
Player Bobby Fischer has moved. Turn 10.
Player Boris Spassky has moved. Turn 11.
Player Bobby Fischer has moved. Turn 12.
Player Boris Spassky has moved. Turn 13.
Player Bobby Fischer has moved. Turn 14.
Player Boris Spassky has moved. Turn 15.
Player Bobby Fischer has moved. Turn 16.
Player Boris Spassky has moved. Turn 17.
Player Bobby Fischer has moved. Turn 18.
Player Boris Spassky has moved. Turn 19.
Player Bobby Fischer has moved. Turn 20.
Player Boris Spassky has moved. Turn 21.
Player Bobby Fischer has moved. Turn 22.
Player Boris Spassky has moved. Turn 23.
Player Bobby Fischer has moved. Turn 24.
Player Boris Spassky has moved. Turn 25.
Player Bobby Fischer was check mated and loses!
*/
Listing 1-15

Concurrent moves in chess

The first line within the for-loop of the goroutine player blocks until an int is taken out of the move channel. With a 5 percent probability, the player loses the game and sets quit to true.

After outputting that the player has moved and posting the player’s turn, it puts an int back into the move channel, freeing the other player to move.

Fibonacci Numbers Using Goroutines

The next example, in Listing 1-16, shows how we can output a sequence of Fibonacci numbers using a goroutine.
package main
import "fmt"
func fibonacci(c chan<- int, quit <-chan bool) {
    x, y := 0, 1
    for {
        select {
        case c <- x:
            x, y = y, x + y // Generates the sequence
        case <- quit:
            fmt.Println("quit")
            return
        }
    }
}
func main() {
    c := make(chan int)
    quit := make(chan bool)
    go func() {
        for i := 0; i < 20; i++ {
            fmt.Println(<-c)
        }
        quit <- true
    }()
    fibonacci(c, quit)
}
/* Output
0
1
1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
1597
2584
4181
quit
*/
Listing 1-16

Fibonacci numbers using a goroutine

The first parameter, c, in function fibonacci puts information into the channel, and the second parameter, quit, takes information out of the channel.

The goroutine is launched within main as an internal function. The Println(<-c) statement blocks until the fibonacci function puts the value x into the integer channel c.

The select statement in function fibonacci either takes the next value of x into channel c or ends the program as soon as quit becomes true. The actual fibonacci sequence is computed as the second line within the case c <-x statement.

In the next section, we examine the possible performance improvements that are attainable by using concurrency.

1.4 Benchmarking Concurrent Applications

The goal in using concurrency is to speed up program execution. There is overhead in deploying goroutines, so sometimes, using concurrency is counterproductive. Because debugging concurrent code is challenging and dealing with deadlocks and race conditions is sometimes tricky, one needs to be careful when crafting concurrent solutions to a problem. Testing a concurrent application and comparing its performance with a nonconcurrent solution is useful.

In this section, we present several applications in which we compare the performance of a concurrent solution with a nonconcurrent solution. Since the result of a benchmark test is dependent on the processor and memory used, the ambient workload of the machine (how many processes are running in the background), and the machine architecture, one must be careful in generalizing and possibly drawing incorrect conclusions from a benchmark result.

Consider the program in Listing 1-17. Here, we compare the time required to construct and sum 100 million floating-point numbers into a slice with and without concurrency.
package main
import "fmt"
import "time"
import "sync"
var output1 float64
var output2 float64
var output3 float64
var output4 float64
var wg sync.WaitGroup
func worker1() {
    defer wg.Done()
    var output []float64
    sum := 0.0
    for index := 0; index < 100_000_000; index++ {
       output = append(output, 89.6)
       sum += 89.6
    }
    output1 = sum
}
func worker2() {
    defer wg.Done()
    var output []float64
    sum := 0.0
    for index := 0; index < 100_000_000; index++ {
       output = append(output, 64.8)
       sum += 64.8
    }
    output2 = sum
}
func worker3() {
    defer wg.Done()
    var output []float64
    sum := 0.0
    for index := 0; index < 100_000_000; index++ {
       output = append(output, 956.8)
       sum += 956.8
    }
    output3 = sum
}
func worker4() {
    defer wg.Done()
    var output []float64
    sum := 0.0
    for index := 0; index < 100_000_000; index++ {
       output = append(output, 1235.8)
       sum += 1235.8
    }
    output4 = sum
}
func main() {
    wg.Add(8)
    // Compute time with no concurrent processing
    start := time.Now()
    worker1()
    worker2()
    worker3()
    worker4()
    elapsed := time.Since(start)
    fmt.Println(" Time for 4 workers in series: ", elapsed)
    fmt.Printf("Output1: %f Output2: %f   Output3: %f   Output4: %f ",
                 output1, output2, output3, output4)
    // Compute time with concurrent processing
    start = time.Now()
    go worker1()
    go worker2()
    go worker3()
    go worker4()
    wg.Wait()
    elapsed = time.Since(start)
    fmt.Println(" Time for 4 workers in parallel: ", elapsed)
    fmt.Printf("Output1: %f Output2: %f   Output3: %f   Output4: %f",
                 output1, output2, output3, output4)
}
/* Output on a Macbook Pro with M1 Max chip with 10-core CPU, 32-core GPU, and 16-core Neural Engine
Time for 4 workers in series:  1.133640541s
Output1: 8960000016.634367
Output2: 6480000011.637030
Output3: 95680000176.244049
Output4: 123580000205.352280
Time for 4 workers in parallel:  756.305958ms
Output1: 8960000016.634367
Output2: 6480000011.637030
Output3: 95680000176.244049
Output4: 123580000205.352280
*/
Listing 1-17

Computing Sum With and Without Concurrency

Each worker function appends a float64 value to construct an output slice of 100 million values while computing the sum in the slice.

In main, we compute and output the computation time if the worker functions are executed sequentially. Then we execute the four worker functions concurrently using goroutines. We compare the computation time and verify the correctness of the results by outputting the sums with and without concurrency.

The computation time running the four worker functions concurrently is 67 percent the time running the four functions sequentially. As expected, the sums computed are the same.

Suppose we wish to speed up the computation of summing a sequence of numbers by using concurrency. Listing 1-18 demonstrates this.
package main
import (
    "fmt"
    "time"
)
const (
    NumbersToSum = 10_000_000
)
func sum(s []float64, c chan<- float64) {
    // A generator that puts data into channel
    sum := 0.0
    for _, v := range s {
        sum += float64(v)
    }
    c <- sum // blocks until c is taken out of the channel
}
func plainSum(s []float64) float64 {
    sum := 0.0
    for _, v := range s {
        sum += float64(v)
    }
    return sum
}
func main() {
    s := []float64{}
    for i := 0; i < NumbersToSum; i++ {
        s = append(s, 1.0)
    }
    c := make(chan float64)
    start := time.Now()
    go sum(s[:len(s) / 2], c)
    go sum(s[len(s) / 2 :], c)
    first, second := <-c, <-c // receive from each c
    elapsed := time.Since(start)
    fmt.Printf("first: %f  second: %f elapsed time: %v", first, second,
                elapsed)
    start = time.Now()
    answer := plainSum(s)
    elapsed = time.Since(start)
    fmt.Printf(" plain sum: %f elapsed time: %v", answer, elapsed)
}
/*
first: 5000000.000000  second: 5000000.000000 elapsed time: 5.864275ms
plain sum: 10000000.000000 elapsed time: 11.601511ms
*/
Listing 1-18

Using concurrency to speed up the computation of sum

By summing half the numbers in each of two goroutines, a substantial improvement in execution time occurs as is evident in the program output.

The two goroutines perform their computation in a for-loop concurrently and return their values by assigning to the channel variable c. In main, the assignment of the two sums to first and second is blocked until both values are assigned to the channel.

Generating Prime Numbers Using Concurrency

Next, we turn to the generation of prime numbers. The classic algorithm for doing this is the Sieve of Eratosthenes. This is an extremely fast nonconcurrent algorithm.

The goal is to find all the prime numbers up to a specified number, say, ten million. A prime number is an integer that is only divisible by 1 or itself. The first several prime numbers are 2, 3, 5, 7, 11, 13, 17, 19, 23. With the exception of 2, all other prime numbers are odd numbers.

Sieve of Eratosthenes Algorithm

The Sieve of Eratosthenes algorithm is presented in the following function:
func SieveOfEratosthenes(n int) []int {
    // Finds all primes up to n
    primes := make([]bool, n+1)
    for i := 2; i < n+1; i++ {
        primes[i] = true
    }
    The sieve logic for removing non-prime indices
    for p := 2; p * p <= n; p++ {
        if primes[p] == true {
            // Update all multiples of p
            for i := p * 2; i <= n; i += p {
                primes[i] = false
            }
        }
    }
    // return all prime numbers <= n
    var primeNumbers []int
    for p := 2; p <= n; p++ {
        if primes[p] == true {
            primeNumbers = append(primeNumbers, p)
        }
    }
    return primeNumbers
}

A slice of bool is initialized to contain n + 1 boolean values. Each element in the slice is initialized to true, indicating that all are initially considered to be primes.

In the for-loop that follows, an index variable p is run from 2 up to the square root of n. If the value prime[p] is true (indicating that p is a prime), all indices that are multiples of p are removed from the primes slice. Say, n = 20. When p is 2, the prime slice is set to false at the following indices: 4, 6, 8, 10, 12, 14, 16, 18, 20. When p is 3, the prime slice is set to false at the following indices: 6, 9, 12, 15, 18. When p is 4, the prime slice is set to false at the following indices: 8, 12, 16, 20. Since p = 5 squared exceeds 20, we are done. The sieve has done its work. The indices whose prime values have not been set to false are 2, 3, 5, 7, 11, 13, 17, and 19.

Listing 1-19 presents a program for benchmarking the performance of the sieve.
// Sieve of Eratosthenes
package main
import (
    "fmt"
    "time"
)
const LargestPrime = 10_000_000
func SieveOfEratosthenes(n int) []int {
    // Finds all primes up to n
    primes := make([]bool, n+1)
    for i := 2; i < n+1; i++ {
        primes[i] = true
    }
    // The Sieve logic
    for p := 2; p*p <= n; p++ {
        if primes[p] == true {
            // Update all multiples of p
            for i := p * 2; i <= n; i += p {
                primes[i] = false
            }
        }
    }
    // return all prime numbers <= n
    var primeNumbers []int
    for p := 2; p <= n; p++ {
        if primes[p] == true {
            primeNumbers = append(primeNumbers, p)
        }
    }
    return primeNumbers
}
func main() {
    start := time.Now()
    sieve := SieveOfEratosthenes(LargestPrime)
    elapsed := time.Since(start)
    fmt.Println(" Computation time: ", elapsed)
    fmt.Println("Largest prime: ", sieve[len(sieve)-1])
}
/* Output
Computation time: 28.881792ms
Number of primes = 664579
*/
Listing 1-19

Benchmarking the Sieve of Eratosthenes

Lest you think that all concurrent solutions are superior, consider the concurrent solution to generating prime numbers in Listing 1-20.

The concurrent solution is so slow compared to the nonconcurrent Sieve of Eratosthenes presented in Listing 1-19 that the constant LargestPrime is lowered by two orders of magnitude to 100,000 instead of the 10 million. Even then, the solution is slower.
// A concurrent prime sieve
package main
import (
    "fmt"
    "time"
)
const LargestPrime = 100_000
var primes []int
// Send the sequence 3, 5, ... to channel 'ch'.
func Generate(prime1 chan<- int) {
    for i := 3; ; i += 2 {
        prime1 <- i // Send 'i' to channel prime1.
    }
}
// Copy the values from channel 'in' to channel 'out',
// removing those divisible by 'prime'.
func Filter(in <-chan int, out chan<- int, prime int) {
    for {
        i := <-in // Receive value from 'in'.
        if i % prime != 0 {
            out <- i // Send 'i' to 'out'.
        }
    }
}
func main() {
    start := time.Now()
    prime1 := make(chan int) // Create a new channel.
    go Generate(prime1)      // Launch goroutine.
    for {
        prime := <-prime1 // Take prime1 out of channel
        if prime > LargestPrime {
            break
        }
        primes = append(primes, prime)
        prime2 := make(chan int)
        go Filter(prime1, prime2, prime)
        prime1 = prime2
    }
    elapsed := time.Since(start)
    fmt.Println("Computation time: ", elapsed)
    fmt.Println("Number of primes = ", len(primes))
}
/* Output
Computation time: 1.462818125s
Number of primes = 9591
*/
Listing 1-20

A concurrent daisy chain solution to generating prime numbers

The use of the remainder operator, %, in the Filter goroutine imposes a significant performance penalty. This goroutine receives information from the in channel and outputs information to the out channel as shown by the directional arrows in the signature to the function.

Can we do better by using another concurrent solution?

Segmented Sieve Algorithm

As a stepping stone toward answering this question, we introduce a modification to the Sieve of Eratosthenes algorithm implemented in Listing 1-19. Listing 1-21 presents a segmented sieve algorithm that provides the basis for a concurrent solution to be presented later.
package main
import (
    "fmt"
    "math"
    "time"
)
const LargestPrime = 10_000_000
var cores int
func SieveOfEratosthenes(n int) []int {
    // Finds all primes up to n
    primes := make([]bool, n+1)
    for i := 2; i < n+1; i++ {
        primes[i] = true
    }
    // The Sieve logic
    for p := 2; p*p <= n; p++ {
        if primes[p] == true {
            // Update all multiples of p
            for i := p * 2; i <= n; i += p {
                primes[i] = false
            }
        }
    }
    // return all prime numbers <= n
    var primeNumbers []int
    for p := 2; p <= n; p++ {
        if primes[p] == true {
            primeNumbers = append(primeNumbers, p)
        }
    }
    return primeNumbers
}
func primesBetween(prime []int, low, high int) []int {
    // Computes the prime numbers between low and high
    // given the initial set of primes from the SieveOfEratosthenes
    limit := high - low
    var result []int
    segment := make([]bool, limit+1)
    for i := 0; i < len(segment); i++ {
        segment[i] = true
    }
    // Find the primes in the current segment based on initial primes
    for i := 0; i < len(prime); i++ {
        lowlimit := int(math.Floor(float64(low)/float64(prime[i])) *
                         float64(prime[i]))
        if lowlimit < low {
            lowlimit += prime[i]
        }
        for j := lowlimit; j < high; j += prime[i] {
            segment[j-low] = false
        }
    }
    for i := low; i < high; i++ {
        if segment[i-low] == true {
            result = append(result, i)
        }
    }
    return result
}
func SegmentedSieve(n int) []int {
    // Each segment is of size square root of n
    // Finds all primes up to n
    var primeNumbers []int
    limit := (int)(math.Floor(math.Sqrt(float64(n))))
    prime := SieveOfEratosthenes(limit)
    for i := 0; i < len(prime); i++ {
        primeNumbers = append(primeNumbers, prime[i])
    }
    low := limit
    high := 2 * limit
    if high >= n {
        high = n
    }
    for {
        if low < n {
            next := primesBetween(prime, low, high)
            // fmt.Printf(" primesBetween(%d, %d) = %v", low, high, next)
            for i := 0; i < len(next); i++ {
                primeNumbers = append(primeNumbers, next[i])
            }
            low = low + limit
            high = high + limit
            if high >= n {
                high = n
            }
        } else {
            break
        }
    }
    return primeNumbers
}
func main() {
    start := time.Now()
    primeNumbers := SegmentedSieve(LargestPrime)
    elapsed := time.Since(start)
    fmt.Println(" Computation time: ", elapsed)
    fmt.Println("Number of primes = ", len(primeNumbers))
}
/* Output
Computation time: 50.557584ms
Number of primes = 664579
*/
Listing 1-21

Segmented sieve algorithm

A series of array segments, each of size square root of n (where n is the highest number to be considered in the array of primes), are defined. Using the prime numbers in the first such array segment, 0 to sqrt(n), which are computed using the SieveOfEratosthenes function, we compute the prime numbers in each succeeding array segment using the primesBetween function.

Let us walk through this function when n is 100 and the size of each array segment is 10. Specifically, let us examine the computation of the primes in the segment 10 to 20.

The primes up to 10 are 2, 3, 7.

The variable low is 10 and high is 20.

An empty slice result is defined, and a segment slice of bool is created of size limit + 1. This segment slice is initialized with values of true.

In a for-loop ranging from 0 to 3 (the length of prime), we define variable lowlimit using
int(math.Floor(float64(low)/float64(prime[i]))* float64(prime[i]))

This evaluates to (10.0 / 2.0) * 2 = 10.

In another for-loop that ranges from lowlimit to high in increments of 2, we set segment at indices 10, 12, 14, 16, 18, and 20 to false.

We advance the index i from 0 to 1 and compute lowlimit as floor(10 / 3) * 3 = 9. Since lowlimit is now less than low, we set it to 12 using lowlimit += prime[1].

In the loop with index j, we set segment slice to false at indices 12, 15, and 18.

Continuing with i set to 2, lowlimit is floor(10 / 7) * 7, which equals 7. Since that is less than low, we reassign it to 14 (lowlimt += prime[2]).

In the j loop, we set the segment slice to false at index 14.

Finally, we capture the values in the segment slice that are still true: 11, 13, 17, and 19.

This pattern is the same for each of the segment slices. The number of computations is the same as the original Sieve of Eratosthenes function. But now the array slice is much smaller (size 10 instead of size 100).

As you can see from the program output, the segmented sieve solution is 1.75 times slower (50.557584ms) compared to the original Sieve of Eratosthenes solution (28.881792ms). This is due to the overhead of defining the ten segment slices and the overhead of the function calls to these slices, not needed in the original sieve computation.

The stage has been set now for a concurrent solution that leverages off the segmented sieve.

Concurrent Sieve Solution

This concurrent solution is presented in Listing 1-22.
package main
import (
    "fmt"
    "math"
    "runtime"
    "sync"
    "time"
)
const LargestPrime = 10_000_000
var cores int
var primeNumbers []int
var m sync.Mutex
var wg sync.WaitGroup
func SieveOfEratosthenes(n int) []int {
    // Finds all primes up to n
    primes := make([]bool, n+1)
    for i := 2; i < n+1; i++ {
        primes[i] = true
    }
    // The Sieve logic
    for p := 2; p*p <= n; p++ {
        if primes[p] == true {
            // Update all multiples of p
            for i := p * 2; i <= n; i += p {
                primes[i] = false
            }
        }
    }
    // return all prime numbers <= n
    var primeNumbers []int
    for p := 2; p <= n; p++ {
        if primes[p] == true {
            primeNumbers = append(primeNumbers, p)
        }
    }
    return primeNumbers
}
func primesBetween(prime []int, low, high int) {
    // Computes the prime numbers between low and high
    // given the initial set of primes from the SieveOfEratosthenes
    defer wg.Done()
    limit := high - low
    segment := make([]bool, limit+1)
    for i := 0; i < len(segment); i++ {
        segment[i] = true
    }
    // Find the primes in the current segment based on initial primes
    for i := 0; i < len(prime); i++ {
        lowlimit := int(math.Floor(float64(low)/float64(prime[i])) *
                         float64(prime[i]))
        if lowlimit < low {
            lowlimit += prime[i]
        }
        for j := lowlimit; j < high; j += prime[i] {
            segment[j-low] = false
        }
        // Each number in [low to high] is mapped to [0, high - low]
        for j := lowlimit; j < high; j += prime[i] {
            segment[j-low] = false
        }
    }
    m.Lock()
    for i := low; i < high; i++ {
        if segment[i-low] == true {
            primeNumbers = append(primeNumbers, i)
        }
    }
    m.Unlock()
}
func SegmentedSieve(n int) {
    limit := int(math.Floor(float64(n) / float64(cores)))
    prime := SieveOfEratosthenes(limit)
    for i := 0; i < len(prime); i++ {
        primeNumbers = append(primeNumbers, prime[i])
    }
    for low := limit; low < n; low += limit {
        high := low + limit
        if high >= n {
            high = n
        }
        wg.Add(1)
        go primesBetween(prime, low, high)
    }
    wg.Wait()
}
func main() {
    cores = runtime.NumCPU()
    start := time.Now()
    SegmentedSieve(LargestPrime)
    elapsed := time.Since(start)
    fmt.Println(" Computation time for concurrrent: ", elapsed)
    fmt.Println("Number of primes = ", len(primeNumbers))
}
/* Output
Computation time for concurrrent: 19.783666ms
Number of primes = 664579
*/
Listing 1-22

Concurrent segmented sieve

The concurrency is achieved in function SegmentedSieve, which launches a series of goroutines, primesBetween, and uses a WaitGroup to block the completion of SegmentedSieve until all the goroutines have completed their work.

To prevent a race condition from occurring, a mutex, m, is used at the end of each goroutine to guarantee that the assignment to the globally shared primeNumbers slice is controlled using an m.Lock() at the beginning of the assignment loop and an m.UnLock() at the end of this assignment loop.

The time required to obtain prime numbers up to the number 10 million is 19.78366ms, which is smaller than the Sieve of Eratosthenes computation, which requires 28.881792ms. The segment size is computed by choosing a number of cores given by runtime.NumCPU(). In principle, this should allow a computation that utilizes each of the computer cores approximating parallel processing. The use of the mutex to protect against a race condition compromises the efficiency of the concurrent solution but is essential using the approach taken to avoid a race condition.

The sequence of primes that are generated using the concurrent segmented sieve is not in order but is complete. This is because the goroutines run asynchronously in random order.

1.5 Summary

In this chapter, we introduced and illustrated the use of generic types. We demonstrated that using generic types can greatly simplify application development by avoiding duplication of code each time we change an underlying type used by the code. We set the stage for using generic types in the data structures and algorithms to be presented throughout this book.

We also demonstrated the potential benefits of using concurrency. We showed that the use of concurrency does not automatically guarantee improved performance. We looked at the use of goroutines and channels as a vehicle of communication between goroutines. We introduced the mutex as a construct for avoiding race conditions and the WaitGroup as a construct for assuring some synchronization between goroutines.

In the next chapter, we enter the world of algorithm design. We discuss methods for characterizing algorithm efficiency. We look at some classic sorting algorithms and the use of concurrency to attain faster sorting.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.29.73