Here is another code review interview question for you. This question is more advanced than the previous ones and is targeted toward a more senior audience. The problem requires knowledge of slices and sharing data between parallel processes.
If you're not familiar with the slices and how they are constructed, please check out my previous article about the Slice Header
A data race occurs when two or more threads (or goroutines, in the case of Go) concurrently access shared memory, and at least one of those accesses is a write operation. If there are no proper synchronization mechanisms (such as locks or channels) in place to manage access, the result can be unpredictable behavior, including corruption of data, inconsistent states, or crashes.
In essence, a data race happens when:
Because of this, the order in which the threads or goroutines access or modify the shared memory is unpredictable, leading to non-deterministic behavior that can vary between runs.
---------------------- --------------------- | Thread A: Write | | Thread B: Read | ---------------------- --------------------- | 1. Reads x | | 1. Reads x | | 2. Adds 1 to x | | | | 3. Writes new value | | | ---------------------- --------------------- Shared variable x (Concurrent access without synchronization)
Here, Thread A is modifying x (writing to it), while Thread B is reading it at the same time. If both threads are running concurrently and there’s no synchronization, Thread B could read x before Thread A has finished updating it. As a result, the data could be incorrect or inconsistent.
Question: One of your teammates submitted the following code for a code review. Please review the code carefully and identify any potential issues.
And here the code that you have to review:
package main import ( "bufio" "bytes" "io" "math/rand" "time" ) func genData() []byte { r := rand.New(rand.NewSource(time.Now().Unix())) buffer := make([]byte, 512) if _, err := r.Read(buffer); err != nil { return nil } return buffer } func publish(input []byte, output chanWhat we have here?
The publish() function is responsible for reading the input data chunk by chunk and sending each chunk to the output channel. It begins by using bytes.NewReader(input) to create a reader from the input data, which allows the data to be read sequentially. A buffer of size 8 is created to hold each chunk of data as it’s being read from the input. During each iteration, reader.Read(buffer) reads up to 8 bytes from the input, and the function then sends a slice of this buffer (buffer[:n]) containing up to 8 bytes to the output channel. The loop continues until reader.Read(buffer) either encounters an error or reaches the end of the input data.
The consume() function handles the data chunks received from the channel. It processes these chunks using a bufio.Scanner, which scans each chunk of data, potentially breaking it into lines or tokens depending on how it’s configured. The variable b := scanner.Bytes() retrieves the current token being scanned. This function represents a basic input processing.
The main() creates a buffered channel chunkChannel with a capacity equal to workersCount, which is set to 4 in this case. The function then launches 4 worker goroutines, each of which will read data from the chunkChannel concurrently. Every time a worker receives a chunk of data, it processes the chunk by calling the consume() function. The publish() function reads the generated data, breaks it into chunks of up to 8 bytes, and sends them to the channel.
The program uses goroutines to create multiple consumers, allowing for concurrent data processing. Each consumer runs in a separate goroutine, processing chunks of data independently.
If you run this code, noting suspicious will happen:
[Running] go run "main.go" [Done] exited with code=0 in 0.94 secondsBut there is a problem. We have a Data Race Risk. In this code, there’s a potential data race because the publish() function reuses the same buffer slice for each chunk. The consumers are reading from this buffer concurrently, and since slices share underlying memory, multiple consumers could be reading the same memory, leading to a data race. Let's try to use a race detection. Go provides a built-in tool to detect data races: the race detector. You can enable it by running your program with the -race flag:
go run -race main.goIf we add the -race flag to the run command we will receive the following output:
[Running] go run -race "main.go" ================== WARNING: DATA RACE Read at 0x00c00011e018 by goroutine 6: runtime.slicecopy() /GOROOT/go1.22.0/src/runtime/slice.go:325 0x0 bytes.(*Reader).Read() /GOROOT/go1.22.0/src/bytes/reader.go:44 0xcc bufio.(*Scanner).Scan() /GOROOT/go1.22.0/src/bufio/scan.go:219 0xef4 main.consume() /GOPATH/example/main.go:40 0x140 main.main.func1() /GOPATH/example/main.go:55 0x48 Previous write at 0x00c00011e018 by main goroutine: runtime.slicecopy() /GOROOT/go1.22.0/src/runtime/slice.go:325 0x0 bytes.(*Reader).Read() /GOROOT/go1.22.0/src/bytes/reader.go:44 0x168 main.publish() /GOPATH/example/main.go:27 0xe4 main.main() /GOPATH/example/main.go:60 0xdc Goroutine 6 (running) created at: main.main() /GOPATH/example/main.go:53 0x50 ================== Found 1 data race(s) exit status 66 [Done] exited with code=0 in 0.94 secondsThe warning you’re seeing is a classic data race detected by Go’s race detector. The warning message indicates that two goroutines are accessing the same memory location (0x00c00011e018) concurrently. One goroutine is reading from this memory, while another goroutine is writing to it at the same time, without proper synchronization.
The first part of the warning tells us that Goroutine 6 (which is one of the worker goroutines in your program) is reading from the memory address 0x00c00011e018 during a call to bufio.Scanner.Scan() inside the consume() function.
Read at 0x00c00011e018 by goroutine 6: runtime.slicecopy() /GOROOT/go1.22.0/src/runtime/slice.go:325 0x0 bytes.(*Reader).Read() /GOROOT/go1.22.0/src/bytes/reader.go:44 0xcc bufio.(*Scanner).Scan() /GOROOT/go1.22.0/src/bufio/scan.go:219 0xef4 main.consume() /GOPATH/example/main.go:40 0x140 main.main.func1() /GOPATH/example/main.go:55 0x48The second part of the warning shows that the main goroutine previously wrote to the same memory location (0x00c00011e018) during a call to bytes.Reader.Read() inside the publish() function.
Previous write at 0x00c00011e018 by main goroutine: runtime.slicecopy() /GOROOT/go1.22.0/src/runtime/slice.go:325 0x0 bytes.(*Reader).Read() /GOROOT/go1.22.0/src/bytes/reader.go:44 0x168 main.publish() /GOPATH/example/main.go:27 0xe4 main.main() /GOPATH/example/main.go:60 0xdcThe final part of the warning explains that Goroutine 6 was created in the main function.
Goroutine 6 (running) created at: main.main() /GOPATH/example/main.go:53 0x50In this case, while one goroutine (Goroutine 6) is reading from the buffer in consume(), the publish() function in the main goroutine is simultaneously writing to the same buffer, leading to the data race.
------------------- -------------------- | Publisher | | Consumer | ------------------- -------------------- | | v | 1. Read data into buffer | | | v | 2. Send slice of buffer to chunkChannel | | | v | ---------------- | | chunkChannel | | ---------------- | | | v | 3. Consume reads from slice | v 4. Concurrent access (Data Race occurs)Why the Data Race Occurs
The data race in this code arises because of how Go slices work and how memory is shared between goroutines when a slice is reused. To fully understand this, let’s break it down into two parts: the behavior of the buffer slice and the mechanics of how the race occurs. When you pass a slice like buffer[:n] to a function or channel, what you are really passing is the slice header which contains a reference to the slice’s underlying array. Any modifications to the slice or the underlying array will affect all other references to that slice.
buffer = [ a, b, c, d, e, f, g, h ]func publish(input []byte, output chanIf you send buffer[:n] to a channel, both the publish() function and any consumer goroutines will be accessing the same memory. During each iteration, the reader.Read(buffer) function reads up to 8 bytes from the input data into this buffer slice. After reading, the publisher sends buffer[:n] to the output channel, where n is the number of bytes read in the current iteration.
The problem here is that buffer is reused across iterations. Every time reader.Read() is called, it overwrites the data stored in buffer.
At this point, if one of the worker goroutines is still processing the first chunk, it is now reading stale or corrupted data because the buffer has been overwritten by the second chunk. Reusing a slice neans sharing the same memory.
To avoid the race condition, we must ensure that each chunk of data sent to the channel has its own independent memory. This can be achieved by creating a new slice for each chunk and copying the data from the buffer to this new slice. The key fix is to copy the contents of the buffer into a new slice before sending it to the chunkChannel:
chunk := make([]byte, n) // Step 1: Create a new slice with its own memory copy(chunk, buffer[:n]) // Step 2: Copy data from buffer to the new slice outputWhy this fix works? By creating a new slice (chunk) for each iteration, you ensure that each chunk has its own memory. This prevents the consumers from reading from the buffer that the publisher is still modifying. copy() function copies the contents of the buffer into the newly allocated slice (chunk). This decouples the memory used by each chunk from the buffer. Now, when the publisher reads new data into the buffer, it doesn’t affect the chunks that have already been sent to the channel.
------------------------- ------------------------ | Publisher (New Memory) | | Consumers (Read Copy) | | [ a, b, c ] --> chunk1 | | Reading: chunk1 | | [ d, e, f ] --> chunk2 | | Reading: chunk2 | ------------------------- ------------------------ ↑ ↑ (1) (2) Publisher Creates New Chunk Consumers Read SafelyThis solution works is that it breaks the connection between the publisher and the consumers by eliminating shared memory. Each consumer now works on its own copy of the data, which the publisher does not modify. Here’s how the modified publish() function looks:
func publish(input []byte, output chanSummary
Slices Are Reference Types:
As mentioned earlier, Go slices are reference types, meaning they point to an underlying array. When you pass a slice to a channel or a function, you’re passing a reference to that array, not the data itself. This is why reusing a slice leads to a data race: multiple goroutines end up referencing and modifying the same memory.Memory Allocation:
When we create a new slice with make([]byte, n), Go allocates a separate block of memory for that slice. This means the new slice (chunk) has its own backing array, independent of the buffer. By copying the data from buffer[:n] into chunk, we ensure that each chunk has its own private memory space.Decoupling Memory:
By decoupling the memory of each chunk from the buffer, the publisher can continue to read new data into the buffer without affecting the chunks that have already been sent to the channel. Each chunk now has its own independent copy of the data, so the consumers can process the chunks without interference from the publisher.Preventing Data Races:
The main source of the data race was the concurrent access to the shared buffer. By creating new slices and copying the data, we eliminate the shared memory, and each goroutine operates on its own data. This removes the possibility of a race condition because there’s no longer any contention over the same memory.Conclusion
The core of the fix is simple but powerful: by ensuring that each chunk of data has its own memory, we eliminate the shared resource (the buffer) that was causing the data race. This is achieved by copying the data from the buffer into a new slice before sending it to the channel. With this approach, each consumer works on its own copy of the data, independent of the publisher’s actions, ensuring safe concurrent processing without race conditions. This method of decoupling shared memory is a fundamental strategy in concurrent programming. It prevents the unpredictable behavior caused by race conditions and ensures that your Go programs remain safe, predictable, and correct, even when multiple goroutines are accessing data concurrently.
It's that easy!
免责声明: 提供的所有资源部分来自互联网,如果有侵犯您的版权或其他权益,请说明详细缘由并提供版权或权益证明然后发到邮箱:[email protected] 我们会第一时间内为您处理。
Copyright© 2022 湘ICP备2022001581号-3