Interested in writing a CLI? Perhaps you need a performant backend application?
I’ve already kind of gone over most of the basic features of Go in a previous series. This article is a fresh look with a single focus - learn Golang in one sitting by learning what’s different from other languages and what is the same.
Go is a fairly basic computer language without a ton of legacy garbage. There aren’t a ton of deep gotchas and you can do a lot with it by simply using the foundational tools of every programming language: variables, loops, arrays, strings, and return statements. Types and routines may be somewhat new for you but you probably know more than you think when it comes to Go. If you are already familiar with Async Await in Javascript, these concepts will not be very challenging to you. If you are brand new to them, I try to break it down in a chunk-and-chew approach.
Go is quickly becoming a popular language for DevOps communities due to its speed and simplicity. It’s not on a top 10 list but it does rank 13th just above Ruby and Rust with Dart trailing close behind. If you know me, I’m a huge fan of Dart (flutter) and think it’s the future so learning these now is only going to lend us a hand later.
See the GitHub rankings here: https://pypl.github.io/PYPL.html.
The important takeaway besides popularity is Go’s versatility, simplicity, and amazing ability to create highly performant CLIs.
What are we going to learn?
First, we are going to build a deck of cards, it’s a project I’ve already covered in a previous article but the concepts are sound and easy to mentally picture.
We nail down the key concepts and then move on to more abstract ideas such as OOP, testing, maps, interfaces, and finally goroutines and channels.
Local Setup
I have an in-depth setup guide here: https://codingwithdrew.com/getting-started-with-go/ but if you want a simplified version of it, follow along below:
Install Go - https://golang.org/dl
Install VS Code if you haven’t already.
In terminal run: xcode-select --install
This can take some time to run.
Install the go extension in VSCode - this unlocks the benefits of VS code.
https://marketplace.visualstudio.com/items?itemName=golang.go
After you have it installed, create a new file called main.go
and close the vscode entirely.
Reopen the VS code and you should be prompted to install go dependencies. Install all of them.
When that is completed let’s create our first “hello world” application.
package main import "fmt" func main() { fmt.Println("Hello World") }
In the next section, let’s break down the first sample Hello World application we wrote.
Learning the Basics
Go has a lot of boilerplate. Breaking down the code below that we created in main.go, let’s answer six basic questions.
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
How do I use the go CLI?
Command | Description |
---|---|
go build | Compiles a bunch of go source code files. Does not execute the code. Produces an executable file. |
go run | Compiles and executes one or more files. Does not produce an executable file. |
go fmt | Formats all the code in each file in the current directory |
go install | Compiles and installs a package |
go get | Downloads the raw source code of someone else’s package. Example: go get -u github.com/gobuffalo/flect |
go test | Runs any tests associated with the current go project |
To run the application:
go run main.go
You should see a terminal output of “Hello World”
There is another way to do this with a web browser using the extension:
In main.go, clear all the code and type helloweb
then press tab. Save the file.
Run the application again. go run main.go
and open a browser to http://localhost:8080/greet and you should see the hello world application running there with a time stamp.
What does package main
mean?
What is a package? Why is this necessary? Why do we call it main?
In go, there are two different types of packages.
- Executable package - Generates a file we can run (An executable file). These are primarily used for “doing” something.
- Reusable package - Code used as “helpers”, it’s modular code that we can reuse in other packages. These are akin to libraries.
How do we know if it’s executable or reusable?
The name of the package determines if the package is executable or not. Main is used to make an executable package. It’s a sacred name. Any other name would be a reusable package.
Lastly, any time we create an executable package (main) it must also have a function inside it called “main” like this: func main(){}
What does import "fmt"
mean?
The import statement is used to give our package access to the code inside of another package named “fmt”. fmt is a standard package that is included in go’s standard library.
By default, our main package has no access to any of the packages in the standard library. We would need import them in order to link them together.
Packages in the standard library do not need any special import statement requirements, just the name, as they are built into go. Here is the standard library for go: https://pkg.go.dev/std and here is the standard library documentation for fmt: https://pkg.go.dev/fmt.
We can also import packages from other developers outside of the standard library using the go get -u github.com/gobuffalo/flect
command. After we have it, we can use an import statement like this:
What is func
?
Chances are, go isn’t your first language and if it is, I’d strongly recommend stopping here and learning bash or python first then come back.
func
is a reserved keyword in go that is used to declare a function and operates exactly like every other language I’ve learned to date. There isn’t anything really new here. In case you need a visual:
How is the main.go file organized?
Go always follows the same pattern in every file we create. It will look like this:
Code | Description |
---|---|
package main | Package Declaration |
import “fmt” | Import other packages that we need |
func main(){ fmt.Println(”Hello World”) } | Declares a function - in main package, this function must be called “main”. This function tells go what to do. |
How do we comment our code?
In go it’s similar to Javascript in that you comment with //
for a single line or /* Some text */
for multiline comments.
Basic Go Variable Types
Go is a statically typed language like Java and Typescript. We have to specify the type of variable is assigned. Javascript and python are not strictly typed meaning you can assign any type of value to a variable without specifying the types.
Type | Example |
---|---|
Bool | true or false |
string | "hello world” |
int | 0, -1000, 4 |
foat64 | 1.2345 |
[] | Arrays/slices []string, []int |
There are many other types but the types above are the most common and basic types.
How do I declare a variable
Go is capable of “guessing” types based on the value provided using the following format:
card := “Ace of Spades”
This syntax is only used when assigning a new variable. When updating a variable, we do not use the :
.
Type conversion
We can convert any type we want into another type by specifying the type we want followed by passing it the value we have.
It looks something like this:
[]type(value)
String interpolation
String interpolation in go is akin to python2 where you make your statement and use placeholders such as %s
for strings and %d
for digits followed by the variable in the order they are printed. To use it, you will need to use a method of fmt called Sprintf()
Here is an example:
package main
import "fmt"
date := fmt.Sprintf("%d-%d-%d", year, month, day)
time := fmt.Sprintf("%d:%d:%d", hour, minute, second)
datetime := fmt.Sprintf("%s,%s", date, time)
Arrays and Slices
One of the annoying parts of learning different languages is the different names for the same things and having to reconcile that. Go has slice but if you use any other language, slice is a method build into arrays to cut an array.
In go arrays are a fixed length list of things whereas a slice is an array that can shrink or grow. This is a slightly new concept compared to other languages I’ve learned.
Slices and Arrays must both be defined with a type and cannot have mixed types.
Slice
cards := []string{ "Ace of Diamonds", "King of hearts"}
In the example above, we create a slice of type string and then pass it the values in the slice.
We can use append()
to add to new items to the array.
cards := []string{ "Ace of Diamonds", "King of hearts"}
cards = append(cards, "Two of Spades")
fmt.Println(cards)
// append doesn't edit the original cards, it creates a new one
Every language we have worked with on at codingwithdrew.com are 0 indexed data structures. Slices in Go are no different so the first element in the slice is always 0. Just like other languages, use a familiar syntax. cards[0]
will pull out “Ace of Diamonds”.
Range Syntax on Arrays & Slices
I would compare the data access rules similar to Python’s lists more than anything because you can “slice” using variable[start:finish]
syntax where you include the start and don’t include the final index.
If you were to leave start
blank, you would grab everything from the beginning to the index (but not including it), for example: cards[:2] would result in “Ace of Diamonds” “King of Hearts” but not “Two of Spades”.
Similarly, you can leave the finish
blank and slice everything from the end up to and including the index you pass, ie: cards[2:]
would result in “Two of Spades”.
How to join a Slice of Strings (This is a bit more advanced admittedly)
Using the standard library we can convert a slice of strings into a string using “strings” from the standard library. Here is how to pull this off:
package main
import (
"fmt"
["strings"](<https://pkg.go.dev/strings>)
)
fruit := []strings{"apple", "banana", "blueberry"}
func fruit toString() string {
strings.Join([]string(Fruit), ",")
}
<aside> 💡 for more information about joining strings read here: https://pkg.go.dev/strings.
Another interesting item to notice in the example above is the import statement has multiple packages so we use this format.
</aside>
Functional programming and Data types with Go
package main
import "fmt"
func main(){
card := newCard()
fmt.Println(card)
}
func newCard() string {
return "Ace of Spades"
}
In the code above you can see that we specify string
after we declare the function newCard()
. This is done to ensure that the value returned from the function is always going to be of type string.
How do you return multiple values of different types?
func example(brand string, shoeSize int) (string, int){
return brand[:shoeSize], brand[shoeSize:]
}
In the example above, you can see that there are 2 types being returned they are separated by a comma and just after the arguments, we see an additional (brand, int)
where we would normally specify a single “type”
Loops
How to iterate over a slice
cards := []string{ "Ace of Diamonds", "King of hearts", "Two of Spades" }
Given the example slice above we can iterate over it using a for loop like this:
for i, card := range cards {
fmt.Println(i, card)
}
We establish a counter i
(index), the iterator name (card), then a range based on the length of cards
using card := range cards
. We use the :=
syntax because each time we iterate through the slice we throw away the card
and re-create a new card
. The for
keyword, will run through the slice of cards while incrementing i
without having to do the extra i++
like you would in javascript until i
is greater than the length of the cards slice.
OOP & Go
Go is not an Object Oriented Language so there is no ability to create classes built in so we have to go about things a special way.
In Go, we would need to create a new custom type to extend a base type with extra functionality to it. To leverage the functionality of the deck type, we have to create a function as a method that acts as a “receiver”. Let’s take a look at what this means.
We are going to break down a very common pattern in go:
In our main.go file, let’s make some changes:
package main
import "fmt"
func main(){
cards := []string{"Ace of Diamonds", "Six of Spades"}
cards = append(cards, newCard())
for i, card := range cards {
fmt.Println(i, card)
}
}
func newCard() string {
return "King of hearts"
}
Let’s create a new file called deck.go
and let’s edit it.
package main // must be declared
// Create a new type of 'deck' that is a slice of strings
type deck []string
Now that we have created this new type, we can edit our main.go file cards type to be of the deck type since deck
and []string
are equivalent.
package main
import "fmt"
func main(){
cards := deck{"Ace of Diamonds", "Six of Spades"}
cards = append(cards, newCard())
for i, card := range cards {
fmt.Println(i, card)
}
}
func newCard() string {
return "King of hearts"
}
We can see this still works by running go run main.go deck.go
Adding functionality to our deck type
Let’s move the for loop and the print statement into the deck so that anytime the deck type is called, it also prints out what cards are in the deck slice.
package main // must be declared
// Create a new type of 'deck' that is a slice of strings
type deck []string
func (d deck) print() {
for i, card := range d {
fmt.Println(i, card)
}
}
package main
import "fmt"
func main(){
cards := deck{"Ace of Diamonds", "Six of Spades"}
cards = append(cards, newCard())
card.print()
}
}
func newCard() string {
return "King of hearts"
}
Now there is some weird d
and card.print()
stuff going on.
What’s going on? What is a receiver?
func (d deck) print(){
}
In the snippet above, any variable that is of type “deck” will have the “print” method assigned to it. The variable d
is a copy of the working instance we are working with. It’s kind of like this
in other languages. In go though, we do not use this
by convention but I personally don’t care, it’s a terrible practice and it gets confusing quickly so my suggestion is to use something like thisDeck deck
instead of first character only. It’s easier to follow and less likely to confuse the reader.
cards.print()
The cards
variable is of type deck so it has the method print()
associated with it. cards
replaces d
(thisDeck
)in the example above. Typically, we would use 1 or 2 characters of the variable name we are extending. You can call it really anything you want but “conventions”...
Let’s create a “newDeck” function that will create and return a list of playing cards - essentially an array of strings. Remember that since it returns a type of “deck”, you have to declare it.
package main // must be declared
// Create a new type of 'deck' that is a slice of strings
type deck []string
func newDeck() deck {
cards := deck{}
cardSuits := []string{"❤️", "♦️", "♣️", "♠️"}
cardValues := []string{"A", "2", "3", "4" "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
// for each suit in cardSuits
for _, suit := range cardSuits{
// for each value in card Values
for _, value :=range cardValues{
// Add a new card of 'value of suit' to the 'cards' deck
cards = append(cards, value+" "+suit)
}
}
return cards
}
func (thisDeck deck) print() {
for i, card := range d {
fmt.Println(i, card)
}
}
What’s that _
thing?
One thing you will notice in the code above is the use of the _
in the for loops and this is because we don’t call or return the i
or whatever we call it and will get an annoyance error in our editor so the simple solution is to just change it to a _
character. This is an oddity of Go but an easy one to learn.
Run our new code!
But first, let’s do some cleanup:
package main
import "fmt"
func main(){
cards := newDeck()
cards.print()
}
If you don’t remember how:
go run main.go deck.go
and you should see a full list of 52 cards you have printed out.
Structs
Structs in Go are just objects, dicts, or hashes in other languages. These are data structures that share common properties.
Just like every other language we’ve learned, let’s consider a “class” of cars - cars all have similar features: make, model, year, and color.
First, we define what fields a car has (the features), and then we create a new value of type car.
Let's name the white 2011 4runner “bertha”.
Let’s get a better sense of what these look like:
package main
import "fmt"
type car struct {
make string
model string
year int
color string
}
func main() {
bertha := car{make: "toyota", model: "4runner", year: 2011, color: "white"}
fmt.Println(bertha)
}
func main() {
bertha := car{"toyota", "4runner", 2011, "white"}
} //don't do it this way!
Go assumes positional arguments in the example above - I dislike this approach because orders can change.
Another way to call a struct is to assign a new variable named bertha of type car with 0 value.
func main () {
var bertha car // zero value
bertha.make = "toyota"
bertha.model = "4runner"
bertha.year = 2011
bertha.color = "white"
}
This method just generates a blank bertha which can then later be updated.
0 values:
Type | Zero value |
---|---|
string | "” |
int | 0 |
float | 0 |
bool | false |
How do I update a struct?
Familiar with dot notation? This one isn’t really new to python, java, or javascript developers so let’s take a look.
...
bertha.color = "yellow"
Embedding structs
Imagine wanting to add owner details to your car? Let’s take a look at how we would do this then talk about the weird bits:
package main
import "fmt"
type car struct {
make string
model string
year int
color string
owner ownerDetails // this line could also just be "ownerDetails"
}
type ownerDetails struct {
name string
email string
}
func main() {
bertha := car{
make: "toyota",
model: "4runner",
year: 2011,
color: "white",
owner: ownerDetails{ // if you just had "ownerDetails" above, you'd need "ownerDetails: ownerDetails{" here.
name: "Drew",
email: "[email protected]",
},
}
fmt.Printf("Car: %v", bertha)
}
In go, if we use the struct declaration format above, we have to use ,
at the end of every item in the multiline struct, even if it’s the last value.
Structs as receiver functions
func (thisCar car) print() {
mt.Printf("Car: %v", thisCar)
}
After creating the code above, we have access to car.print()
method on any struct of car type.
Pass by Value and pointers
If we wanted to create a function that updates the color, we’d need to leverage the same receiver function inside the struct.
package main
import "fmt"
type car struct {
make string
model string
year int
color string
owner ownerDetails // this line could also just be "ownerDetails"
}
type ownerDetails struct {
name string
email string
}
func main() {
bertha := car{
make: "toyota",
model: "4runner",
year: 2011,
color: "white",
owner: ownerDetails{ // if you just had "ownerDetails" above, you'd need "ownerDetails: ownerDetails{" here.
name: "Drew",
email: "[email protected]",
},
}
bertha.updateCar("yellow")
bertha.print()
}
func (thisCar car) updateCar(newColor string) {
thisCar.color = newColor
}
func (thisCar car) print() {
fmt.Printf("Car: %v", thisCar)
}
This code may do something unexpected if you aren’t familiar with pass by value languages like c.
What I’d think would happen when we run this code is it would operate in a straight line from top to bottom and I’d have a struct that would print out to console that had a car struct (bertha) with a color of white that is updated to yellow before printing.
If we run the program, we see that it did not update the color from white to yellow.
This segues well into pointers which is an important topic in go.
Go is a “Pass by Value” language
What does this mean? Pointers are a way to point to the “address” of a block of memory. What happens in the code above is that when we use a receiver (thisCar
) we aren’t addressing the same block of memory as bertha
. Go makes a copy of bertha in memory and because of this, we only get the value of bertha
instead of the updated value of thisCar
. We “pass by” the value of the original object even though, in this context, thisCar == bertha
. This is because thisCar and Bertha have different “addresses” of memory.
So how do we rectify this issue?
Pointers - things get weird
package main
import "fmt"
type car struct {
make string
model string
year int
color string
owner ownerDetails // this line could also just be "ownerDetails"
}
type ownerDetails struct {
name string
email string
}
func main() {
bertha := car{
make: "toyota",
model: "4runner",
year: 2011,
color: "white",
owner: ownerDetails{ // if you just had "ownerDetails" above, you'd need "ownerDetails: ownerDetails{" here.
name: "Drew",
email: "[email protected]",
},
}
berthaPointer := &bertha
berthaPointer.updateCar("yellow")
bertha.print()
}
func (pointerToCar *car) updateCar(newColor string) {
(*pointerToCar).color = newColor
}
func (thisCar car) print() {
fmt.Printf("Car: %v", thisCar)
}
What just happened and why did it work?
When we use the &
operator - what we are doing it is saying “computer, give me the memory address of the value this variable is pointing at.” In this case, we are saying berthaPointer
is the same memory address as bertha
instead of the memory address of thisCar
.
The *pointer
(*car
in this case) is telling the computer “give me the value that this memory address is pointed to”. this is a type description - it’s just letting you know “hey, we are working with a pointer to a car”.
The (*pointerToCar)
is actually an operator and means “I’d like to update the value this pointer is referencing”.
You may want to re-read that, it’s a bit tricky to wrap your head around admittedly but maybe this explains it in simpler terms:
“Turn address
into a value
with *address
"
“Turn value
into a address
with &value
"
Pointer Shortcuts
...
bertha.updateCar("yellow")
bertha.print()
}
func (pointerToCar *car) updateCar(newColor string) {
(*pointerToCar).color = newColor
...
This works as expected because we have the pointer *car
and go allows this. Honestly, this makes life a good bit easier because it’s ugly the other way.
Working with Files
We want to look for a way to interact with the host system and in most cases, there is a standard library tool already created for your use case.
Let’s take a look at how to manipulate data with files.
Head over to pkg.go.dev and search for ioutil
- you should land on this page: https://pkg.go.dev/io/ioutil
Let’s take a look at some code on how to write data to a file.
func WriteFile(filename [string](<https://pkg.go.dev/builtin#string>), data [][byte](<https://pkg.go.dev/builtin#byte>), perm fs.Filemode) [error](<https://pkg.go.dev/builtin#error>)
package main
import (
"io/ioutil"
"log"
"fmt"
)
func main() {
message := []byte("Hello, Gophers!")
fmt.Println(message)
err := ioutil.WriteFile("hello", message, 0644)
if err != nil {
log.Fatal(err)
}
}
In this example, we write data to “hello” with the contents being “Hello, Gophers” (message, is a slice of “byte”) and having 644 permissions set.
This would be the equivalent of saying the following in bash:
echo "Hello, Gophers" >> Hello;
chmod 644 Hello;
What is a []byte
? “Byte Slices”
Byte slices are a string of characters which uses ascii tables which allows the computer to represent each character of a string as a slice of numbers.
Basically, we are converting a string into a new variable called Message and changing its type to byte
which is a slice at the end of the day.
If we ran the code above, we would see that the message “Hello, Gophers!” gets converted to byte code in a slice:
[72 101 108 108 111 44 32 71 111 112 104 101 114 115 33]
Read from file
Start out by reading the documentation for the ReadFile function:
https://pkg.go.dev/io/ioutil#ReadFile
func ReadFile(filename [string](<https://pkg.go.dev/builtin#string>)) ([][byte](<https://pkg.go.dev/builtin#byte>), [error](<https://pkg.go.dev/builtin#error>))
package main
import (
"fmt"
"io/ioutil"
"log"
)
func main() {
content, err := ioutil.ReadFile("testdata/hello")
if err != nil {
log.Fatal(err)
}
fmt.Printf("File contents: %s", content)
}
Assertions - Testing with go
How can we write code to make sure all the functions we write will always work correctly?
Go tests are much simpler in general.
Go testing is not like using frameworks you may be used to like RSpec, mocha, jasmine, selenium, etc!
To make a test, create a new file with a name ending in _test.go
.
To run all tests in a package all you have to do is run go test
.
Example
As soon as you start typing in VS code in a file with the name main_test.go
you will see the built-in features to run and debug your tests.
When writing tests, they should focus on 3 things:
- Describe the data we anticipate and determine a way to check that — one easy way is to check the length of an array/slice or verify a specific index value.
- In a logical statement, what should always resolve to be true?
- If this ____ piece of code fails, nothing else will work.
If you can find the answer to these 3 questions, you can write excellent go code.
When we create a new test, it will look a little funky. We will use some boilerplate code.
package main
import "testing"
func TestNewDeck(t *testing.T){
}
Gross right?
In the code above, the t
is what we call the “Test Handler” and if anything goes wrong with our tests, we tell this value t
what went wrong.
So, if a tests fails, we run code that looks like this:
package main
import "testing"
func TestNewDeck(t *testing.T){
sky := newPlace()
if color(sky) != "blue" {
t.Errorf("Expected blue sky but got %v", color(sky)
}
}
<aside> 💡 %v
instructs go to pass the “value” that is ran as the 2nd argument. “string interpolation”
</aside>
Maps
Maps are very similar to Structs. The keys and values are statically typed where all the keys are the same type and all the values are the same type.
Creating a new map
package main
import "fmt"
func main() {
// 1st approach to creating a map
colors := map[string]string{
"red": "#ff0000",
"green": "00ff00",
"white": "ffffff",
}
fmt.Println("colors")
}
When we run this program we will see that it outputs:
map[red:#ff0000 green: 00ff00]
nothing too earth shattering here, right?
Here are 2 other ways to declare maps:
package main
import "fmt"
func main(){
// 2nd approach to creating a map
colors: := map[string]string{ // zero value approach
}
package main
import "fmt"
func main(){
// 3rd approach
colors: := make(map[string]string) zero value approach
}
Updating maps
We do not have access to maps with the dot notation like we do with structs. Instead, we have to use the map[index]
notation like this: colors[2] = "green": "00fe00"
There is also a built-in function called delete(map, index)
that we can use. For example, delete(colors, 0)
Looping over Maps
package main
import "fmt"
func main() {
// 1st approach to creating a map
colors := map[string]string{
"red": "#ff0000",
"green": "00ff00",
"white": "ffffff",
}
}
To create a loop over a map, you do it just like any other variable. Let’s add one to the code above.
func printMap(thisColors map[string]string) {
for key, value := range thisColors {
fmt.Printf("The Hexcode for %s is %s. ", key, value)
}
}
Then we want to call our function:
printMap(colors)
in our file.
Interfaces
Interfaces are super important in Go Lang. There isn’t a ton of great information out there about how to talk about interfaces without diving head first so I’m going to try to boil it all down into simple pieces anyone can consume.
Right now, we know that in go, every value has a type even if we don’t explicitly specify its type. Our functions also have to specify the type of its arguments and return statements. If we pull that same thread a bit further, we see a limitation of our code.
What if we want to use the same logic from a function but with a different type than was originally written for, would we have to rewrite all our functions? That wouldn’t be very DRY. How does go approach this issue? Learning how to read them is the 1st barrier.
Let’s take a look at how we would approach keeping this code DRY:
package main
import "fmt"
type fountainDrinks struct {}
type alcoholicDrinks struct {}
func main() {
fd := fountainDrink{}
ad := alcoholicDrinks {}
fmt.Println(fd)
fmt.Println(ad)
}
In this cheap example you can see that we have 2 types of drinks, both are structs and we would like to interchangeably print the drink without knowing the type of drink when we write our code. Interfaces have joined the chat.
Interfaces define some behaviors that are common to multiple types.
Interfaces are not generic types, they are implicit meaning we don’t manually say that our custom type satisfies some interface. We also need to know that interfaces are a contract to help us manage types, however, if our custom type’s implementation of a function is broken then the interfaces will not help us.
How to create an interface:
type drink interface {
getDrink() string
}
func printDrink(thisDrink drink) {
fmt.Println(thisDrink.getDrink())
printDrink(fd)
printDrink(ad)
Reader interfaces
Similar to vanilla interfaces, Reader interfaces recognize that there could be very different types that could be inputs for a function.
Check out the docs on https://golang.org/pkg/io/#Reader for more details but basically it allows you to take any type for an input and be able to parse it.
type Reader interface {
Read(p []byte) (n int, err error)
}
“As long as we have a non-zero input, we can then output a common output, byte slice”
Go Routines
Channels and Go routines are both structures that Go leverages to enable concurrent programming.
A great example of a use case for concurrent programming would be creating a website checker.
We are going to create a basic application to start that has no concurrency.
package main
import (
"fmt"
"net/http"
)
func main() {
links := []string{
"<https://freshly.com>",
"<https://status.slack.com>",
"<https://cloudflare.com>",
"<https://drewlearns.com>",
}
//loop through links and make an http request
for _, link := range links {
checkLink(link)
}
}
func checkLink(link string) {
_, err := http.Get(link)
if err != nil {
fmt.Printf("%v might be down!", link)
return
}
fmt.Printf("%v is up", link)
}
If you were to run go run main.go
you’ll see that status reports would run in the order they are listed but not necessarily a desired option since there is a delay between each call because we are waiting for each line to run.
Imagine we had a list of 1000 of these websites to check - if we waited for each one to run, one at a time every minute, eventually, they would go past their anticipated minute and you’d start to backlog or record status’ out of order. We are only ever making one HTTP request at a time.
Perhaps this isn’t the best approach at checking the status of our list of sites.
Instead of a series of quests, perhaps we should start making them in parallel.
Go Routines
When we create a function or program in Go and run it, we kick off a process on our machine. This process is what we would call a “Go Routine”. Each Routine will go through our compiled lines of code and execute it line by line like every other language you’ve learned.
Code that takes time to run is referred to as a “Blocking call”.
We can create a new Routine by adding go
in front of our checkLink(link)
function.
Every time this function is called, a new go routine process is kicked off.
The most similar thing I can think of on how go routines work is how async await works in JavaScript but without all the extra syntax instead we get channels.
- With Javascript async/await you can explicitly manipulate promises (including grouping them for parallel execution), and pauses in execution are always explicit.
- Here is my explanation (with help) of how callbacks and promises work in Javascript: https://drewlearns.com/event-loops-callbacks/
- Goroutines are similar in that execution can happen on multiple CPU threads, so if one Goroutine takes extra CPU time, others will shuttle off to different CPU threads and will still be executed.
If we do not have anything calling back in the main Goroutine to check on child Goroutines, then after it’s running, the main.go file continues trucking along through all the rest of the code without caring about whether the child Goroutines are completed and will exit. When the main (parent) Goroutine exits, all its children also exit. To fix this we introduce a callback or “channel” as Go calls it.
Channels are used to allow different routines to communicate with each other. Channels are the only way for individual routines can communicate with each other and like everything else in Go, they are strictly typed meaning you can only pass one type of data through the channel.
We treat this channel value like anything else in Go, so we have to pass it into our function.
We can send data with channels using 3 different types of syntax:
channel <- 5
Send the value “5” to this channelmynumber <- channel
Wait for the value to be sent into the channel. When we get one, assign the value to “mynumber”fmt.Println(<-channel)
Wait for a value to be sent into the channel, when we get one, log it out immediately.
Unfortunately, we have a blocked channel given what we know right now. Let’s take a look:
package main
import (
"fmt"
"net/http"
)
func main() {
links := []string{
"<https://freshly.com>",
"<https://status.slack.com>",
"<https://cloudflare.com>",
"<https://drewlearns.com>",
}
channelA := make(chan string)
//loop through links and make an http request
for _, link := range links {
go checkLink(link, channelA) // must specify the type
}
fmt.Println(<-channelA)
}
func checkLink(link string, channelA chan string) {
_, err := http.Get(link)
if err != nil {
fmt.Printf("%v might be down!", link)
channelA <- "Down" //send "down" into our channel
return
}
fmt.Printf("%v is up", link)
channelA <- "Up" //send "up" into our channel
}
Looks like if we run this application, it’ll output “freshly.com is up” and then sit on it. This is because when we received a message from the channel, the channel is then blocked so we have to wait until that line of code is ran before the main goroutine is aware that it needs to run the next goroutine. As you can see, the application is now in a frozen state because nothing on the main goroutine is being executed.
We could make 5 print line statements but that’s just ugly and not very DRY.
Let’s just create a loop.
for { // infinite loop to always be checking the status
go checkLink(<-channelA, channelA) // have to pass the channel as 2nd arg
}
That thing is blazing fast now.
Your code should look like this:
package main
import (
"fmt"
"net/http"
)
func main() {
links := []string{
"https://freshly.com",
"https://status.slack.com",
"https://cloudflare.com",
"https://drewlearns.com",
}
channelA := make(chan string)
//loop through links and make an http request
for _, link := range links {
go checkLink(link, channelA) // must specify the type
}
for {
go checkLink(<-channelA, channelA)
}
}
func checkLink(link string, channelA chan string) {
_, err := http.Get(link)
if err != nil {
fmt.Printf("%v might be down!", link)
channelA <- link //send the link into our channel
}
fmt.Printf("%v is up", link)
channelA <- link //send link into our channel
}
Final thoughts
Every time I learn a new language and am super intimidated by it, then learn it, I’m relieved to find out it’s not very different from another language I’ve learned and all the fuss was over nothing.
Drew is a seasoned DevOps Engineer with a rich background that spans multiple industries and technologies. With foundational training as a Nuclear Engineer in the US Navy, Drew brings a meticulous approach to operational efficiency and reliability. His expertise lies in cloud migration strategies, CI/CD automation, and Kubernetes orchestration. Known for a keen focus on facts and correctness, Drew is proficient in a range of programming languages including Bash and JavaScript. His diverse experiences, from serving in the military to working in the corporate world, have equipped him with a comprehensive worldview and a knack for creative problem-solving. Drew advocates for streamlined, fact-based approaches in both code and business, making him a reliable authority in the tech industry.