Goroutines in Golang for High-Performance Concurrency

Written by

As a Software Engineer at Fullstack Labs, our Architect and I have decided to switch our business app from PHP to Golang. One of the main reasons behind this move is performance.

If you're thinking about migrating your app and want a language that's perfect for high-performance concurrent execution, Golang should definitely be on your radar. However, I would like to dive into the Multithreading Go feature in this post. Golang is a champ when it comes to concurrent tasks with built-in support for goroutines and channels. Golang's multithreading is indeed a game changer; it uses goroutines, which are lightweight threads managed by the Go runtime. They're super resource-efficient, so you can have thousands or even millions of them running without slowing down your app.

Here are what I believe to be the six principal attributes of goroutines, along with illustrative examples:

  1. Lightweight: Goroutines are lighter than traditional threads. They consume less memory, often starting with a smaller stack size that can grow and shrink as needed. 
  2. Easy to Create: Goroutines can be started simply by using the go keyword before a function call. This simplicity makes it easier to write concurrent programs without the complexity typically associated with threading.
  3. Efficient Communication: Channels in Go allow goroutines to communicate and synchronize efficiently. Channels eliminate many of the common pitfalls of threaded communication such as deadlocks, race conditions, and thread safety issues.
  4. Native to the Language: Unlike many languages where concurrency is bolted on as a library or afterthought, goroutines are a native concept in Go. This means they are well integrated and supported within the language.
  5. GOMAXPROCS Configuration: The GOMAXPROCS variable allows you to set the maximum number of CPUs that can be executed simultaneously. This level of control lets you fine-tune the performance characteristics of your goroutines.
  6. Support for Parallel Execution: In addition to providing concurrency (executing multiple tasks, though not necessarily simultaneously), goroutines also support genuine parallel execution on systems with multiple cores. This facilitates the efficient allocation of resources and potentially expedited execution times for CPU-bound tasks.

To instantiate a goroutine, simply prepend the function call with the ‘go’ keyword, as demonstrated here:

go myFunction()

Goroutines communicate and synchronize using channels, making it easy to safely pass data between them without worrying about race conditions or other synchronization problems.

Check out this simple example using goroutines and channels:

package main


import (
   "fmt"
   "strconv"
   "time"
)


func printMessage(message string, ch chan<- string) {
   time.Sleep(2 * time.Second)
   ch <- message
}


func main() {
   ch := make(chan string)


   go printMessage("First message Golang!", ch)


   for p := 0; p < 500; p++ {
       go printMessage("Golang message n: " + strconv.Itoa(p), ch)
   }


   for f := 0; f < 500; f++ {
       fmt.Println(<-ch)
   }
}

In this example, we've got a bunch of goroutines running concurrently, each waiting for 2 seconds before sending a message to the ch channel. The main function receives and prints the messages from the channel. You'll notice the messages aren't printed sequentially. This shows how goroutines and channels work together to enable concurrent execution in Go.

Here’s another example of using goroutines and channels in Golang to calculate the factorial of several numbers concurrently.

package main


import (
  "fmt"
)


type FactorialResult struct {
  Num       int
  Factorial int64
}


func factorial(n int, ch chan FactorialResult) {
  var result int64 = 1
  for i := 1; i <= n; i++ {
      result *= int64(i)
  }
  ch <- FactorialResult{Num: n, Factorial: result}
}


func main() {
  numbers := []int{0, 1, 5, 7, 10, 20}
  factorialChannel := make(chan FactorialResult, len(numbers))


  for _, num := range numbers {
      go factorial(num, factorialChannel)
  }


  for i := 0; i < len(numbers); i++ {
      fact := <-factorialChannel
      fmt.Printf("Factorial of %d: %d\n", fact.Num, fact.Factorial)
  }
}

Due to the nature of goroutines, which may complete their calculations at different times, the printed factorials might not appear in the same order as the original numbers array, as demonstrated in the following output:

Factorial of 20: 2432902008176640000
Factorial of 1: 1
Factorial of 0: 1
Factorial of 7: 5040
Factorial of 5: 120
Factorial of 10: 3628800

As seen above, the sequence of the factorials differs from the initial order in the numbers array, showcasing the concurrent execution of goroutines.

In a nutshell, Golang's multithreading is based on goroutines and channels, making it perfect for highly concurrent and efficient tasks. Along with its solid performance and user-friendly nature, Golang is a top choice for developers building apps that need to handle multiple tasks at once.

Let me demonstrate another example of how to download a set number of random images from the Unsplash API, save them locally, and then create a zip file containing those images. It uses goroutines to concurrently download the images, improving the overall performance.

package main


import (
   "archive/zip"
   "encoding/json"
   "fmt"
   "io"
   "net/http"
   "Os"
   "runtime"
   "sync"
)


const (
   unsplashAPIKey  = "UNSPLASH_API_KEY"
   unsplashBaseURL = "https://api.unsplash.com"
   amountImages    = 15
)


type UnsplashPhoto struct {
   ID   string `json:"id"`
   URLs struct {
       Raw string `json:"raw"`
   } `json:"urls"`
}


// getRandomPhotos fetches 'count' random images from Unsplash API
func getRandomPhotos(count int) ([]UnsplashPhoto, error) {
   requestURL := fmt.Sprintf("%s/photos/random?count=%d&client_id=%s", unsplashBaseURL, count, unsplashAPIKey)
   resp, err := http.Get(requestURL)
   if err != nil {
       return nil, err
   }
   defer resp.Body.Close()


   var photos []UnsplashPhoto
   err = json.NewDecoder(resp.Body).Decode(&photos)
   return photos, err
}


// downloadImage downloads an image from the given URL and saves it with the specified file name.
// It signals the WaitGroup when the download is complete.
func downloadImage(url string, fileName string, wg *sync.WaitGroup) {
   defer wg.Done()


   response, err := http.Get(url)
   if err != nil {
       fmt.Println("Error downloading image:", err)
       return
   }
   defer response.Body.Close()


   file, err := os.Create(fileName)
   if err != nil {
       fmt.Println("Error creating file:", err)
       return
   }
   defer file.Close()


   _, err = io.Copy(file, response.Body)
   if err != nil {
       fmt.Println("Error saving image to file:", err)
   }
}


func main() {
   // Set GOMAXPROCS to 7
   runtime.GOMAXPROCS(7)


   // Get amountImages random photos from the Unsplash API
   photos, err := getRandomPhotos(amountImages)
   if err != nil {
       fmt.Println("Error getting random photos:", err)
       return
   }


   // Create a WaitGroup to synchronize the download of all images
   // sync.WaitGroup is a synchronization primitive in Go's sync package that allows
   // you to wait for a collection of goroutines to finish executing.
   // It provides a simple way to ensure that all concurrent tasks (goroutines)
   // have completed before continuing with the rest of the program.
   var wg sync.WaitGroup
   wg.Add(len(photos))


   // Download each image concurrently using goroutines
   for i, photo := range photos {
       fileName := fmt.Sprintf("image%d_%s.jpg", i, photo.ID)
       fmt.Println("Downloading Image: ", fileName)
       go downloadImage(photo.URLs.Raw, fileName, &wg)
   }


   // Wait for all image downloads to complete
   wg.Wait()


   // Create a new zip file to store the downloaded images
   zipFile, err := os.Create("images.zip")
   if err != nil {
       fmt.Println("Error creating zip file:", err)
       return
   }
   defer zipFile.Close()


   zipWriter := zip.NewWriter(zipFile)
   defer zipWriter.Close()


   fmt.Println("Creating zip images.zip...... ")


   // Add each downloaded image to the zip file
   for i, photo := range photos {
       fileName := fmt.Sprintf("image%d_%s.jpg", i, photo.ID)
       file, err := os.Open(fileName)
       if err != nil {
           fmt.Println("Error opening image file:", err)
           continue
       }


       fileInfo, err := file.Stat()
       if err != nil {
           fmt.Println("Error getting file info:", err)
           file.Close()
           continue
       }


       // Create a header for the zip entry
       header, err := zip.FileInfoHeader(fileInfo)
       if err != nil {
           fmt.Println("Error creating zip header:", err)
           file.Close()
           continue
       }


       header.Name = fileName
       zipFileWriter, err := zipWriter.CreateHeader(header)
       if err != nil {
           fmt.Println("Error creating zip file writer:", err)
           file.Close()
           continue
       }


       // Copy the image file content to the zip entry
       _, err = io.Copy(zipFileWriter, file)
       if err != nil {
           fmt.Println("Error writing image to zip file:", err)
       }


       file.Close()
   }


   fmt.Println("Images downloaded and zipped successfully.")
}

As seen in the example above, it is using the Goroutines to download the images in parallel and it is not downloading sequentially. But, you could increase the performance using goroutines to download and add each image to the zip file concurrently as you can see in the following example:

package main


import (
   "archive/zip"
   "fmt"
   "io"
   "net/http"
   "os"
   "sync"
)


var images = []string{
   // Add your 15 image URLs here
}


func downloadImage(url string, zipWriter *zip.Writer, wg *sync.WaitGroup) {
   defer wg.Done()


   response, err := http.Get(url)
   if err != nil {
       fmt.Println("Error downloading image:", err)
       return
   }
   defer response.Body.Close()


   if response.StatusCode != http.StatusOK {
       fmt.Println("Error downloading image, status code:", response.StatusCode)
       return
   }


   fileName := response.Request.URL.Path
   zipFileWriter, err := zipWriter.Create(fileName)
   if err != nil {
       fmt.Println("Error creating zip entry:", err)
       return
   }


   _, err = io.Copy(zipFileWriter, response.Body)
   if err != nil {
       fmt.Println("Error writing image to zip file:", err)
   }
}


func main() {


   zipFile, err := os.Create("images.zip")
   if err != nil {
       fmt.Println("Error creating zip file:", err)
       return
   }
   defer zipFile.Close()


   zipWriter := zip.NewWriter(zipFile)
   defer zipWriter.Close()


   var wg sync.WaitGroup
   wg.Add(len(images))


   for _, imageURL := range images {
       go downloadImage(imageURL, zipWriter, &wg)
   }


   wg.Wait()


   fmt.Println("Images downloaded and zipped successfully.")
}

In the example above, we define a downloadImage function that takes an image URL, a *zip.Writer, and a *sync.WaitGroup. The function downloads the image, adds it to the zip file, and calls wg.Done() to signal that it has finished.

While Golang might not be right for every app or migration, it's definitely worth considering if you need high-performance concurrent execution. Therefore, Golang's simplicity, speed, and scalability make it an attractive choice for developers working on multitasking apps. If that sounds like what you're after, Golang will just be the perfect fit for your project while delivering lasting outstanding results.