Network programming in Go
Categories:
Mastering Network Programming in Go: Sockets, Protocols, and Concurrency

Explore the fundamentals of network programming in Go, covering TCP/UDP sockets, common protocols, and Go's powerful concurrency model for building high-performance network applications.
Go (Golang) is an excellent choice for network programming due to its built-in concurrency primitives (goroutines and channels), a robust standard library, and strong performance characteristics. This article will guide you through the essentials of building network applications in Go, focusing on TCP and UDP communication, and how to leverage Go's concurrency for efficient handling of multiple connections.
Understanding TCP and UDP Protocols
Network programming primarily revolves around two fundamental transport layer protocols: TCP (Transmission Control Protocol) and UDP (User Datagram Protocol). Understanding their differences is crucial for choosing the right protocol for your application.
TCP is a connection-oriented, reliable, ordered, and error-checked protocol. It guarantees that data sent will be received in the same order, without duplication or loss. This reliability comes with overhead, making it suitable for applications where data integrity is paramount, such as web servers (HTTP/HTTPS), file transfers (FTP), and email (SMTP).
UDP is a connectionless, unreliable protocol. It offers no guarantees of delivery, order, or error checking. Data is sent as independent packets (datagrams) without establishing a persistent connection. While less reliable, UDP has lower overhead and is faster, making it ideal for applications where speed is critical and some data loss is acceptable, such as real-time gaming, DNS lookups, and streaming media.
flowchart TD A[Application Layer] --> B{Transport Layer} B --> C{TCP?} C -- Yes --> D[Connection-Oriented] D --> E[Reliable, Ordered] E --> F[Higher Overhead] C -- No --> G[UDP?] G -- Yes --> H[Connectionless] H --> I[Unreliable, Fast] I --> J[Lower Overhead] F --> K[HTTP, FTP, SMTP] J --> K[DNS, Gaming, Streaming]
Decision flow for choosing between TCP and UDP
Building a Basic TCP Server and Client in Go
Go's net
package provides all the necessary primitives for network programming. Let's start by creating a simple TCP echo server and client. The server will listen for incoming connections, read data, and send it back to the client. The client will connect, send a message, and print the server's response.
package main
import (
"bufio"
"fmt"
"log"
"net"
"os"
"strings"
"time"
)
func handleConnection(conn net.Conn) {
defer conn.Close()
// Set a read deadline to prevent slow clients from holding connections indefinitely
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(conn)
for {
message, err := reader.ReadString('\n')
if err != nil {
log.Printf("Error reading from client %s: %v", conn.RemoteAddr(), err)
return
}
message = strings.TrimSpace(message)
if message == ""
continue
}
log.Printf("Received from %s: %s", conn.RemoteAddr(), message)
// Echo back the message
_, err = conn.Write([]byte(fmt.Sprintf("Echo: %s\n", message)))
if err != nil {
log.Printf("Error writing to client %s: %v", conn.RemoteAddr(), err)
return
}
}
}
func main() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
defer listener.Close()
log.Println("TCP Echo Server listening on :8080")
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Failed to accept connection: %v", err)
continue
}
log.Printf("Accepted connection from %s", conn.RemoteAddr())
go handleConnection(conn) // Handle connection concurrently
}
}
package main
import (
"bufio"
"fmt"
"log"
"net"
"os"
"strings"
"time"
)
func main() {
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
log.Fatalf("Failed to connect to server: %v", err)
}
defer conn.Close()
log.Println("Connected to TCP Echo Server on localhost:8080")
reader := bufio.NewReader(os.Stdin)
serverReader := bufio.NewReader(conn)
for {
fmt.Print("Enter message: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "exit" {
break
}
// Send message to server
_, err = conn.Write([]byte(input + "\n"))
if err != nil {
log.Printf("Error sending message: %v", err)
return
}
// Read response from server
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
response, err := serverReader.ReadString('\n')
if err != nil {
log.Printf("Error reading response: %v", err)
return
}
fmt.Printf("Server response: %s", response)
}
log.Println("Client disconnected.")
}
go handleConnection(conn)
in the server. This is Go's powerful concurrency in action. Each new client connection is handled in its own goroutine, allowing the server to accept and process multiple clients simultaneously without blocking.UDP Communication in Go
Implementing UDP communication is slightly different as there's no persistent connection. You simply send datagrams to a destination address and listen for incoming datagrams. Let's create a simple UDP echo server and client.
package main
import (
"fmt"
"log"
"net"
"strings"
)
func main() {
addr, err := net.ResolveUDPAddr("udp", ":8081")
if err != nil {
log.Fatalf("Failed to resolve UDP address: %v", err)
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
log.Fatalf("Failed to listen UDP: %v", err)
}
defer conn.Close()
log.Println("UDP Echo Server listening on :8081")
buffer := make([]byte, 1024)
for {
n, remoteAddr, err := conn.ReadFromUDP(buffer)
if err != nil {
log.Printf("Error reading from UDP: %v", err)
continue
}
message := strings.TrimSpace(string(buffer[:n]))
if message == "" {
continue
}
log.Printf("Received from %s: %s", remoteAddr, message)
// Echo back to the sender
_, err = conn.WriteToUDP([]byte(fmt.Sprintf("Echo: %s", message)), remoteAddr)
if err != nil {
log.Printf("Error writing to UDP: %v", err)
}
}
}
package main
import (
"bufio"
"fmt"
"log"
"net"
"os"
"strings"
"time"
)
func main() {
serverAddr, err := net.ResolveUDPAddr("udp", "localhost:8081")
if err != nil {
log.Fatalf("Failed to resolve server address: %v", err)
}
conn, err := net.DialUDP("udp", nil, serverAddr)
if err != nil {
log.Fatalf("Failed to connect to UDP server: %v", err)
}
defer conn.Close()
log.Println("Connected to UDP Echo Server on localhost:8081")
reader := bufio.NewReader(os.Stdin)
buffer := make([]byte, 1024)
for {
fmt.Print("Enter message: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "exit" {
break
}
// Send message to server
_, err = conn.Write([]byte(input))
if err != nil {
log.Printf("Error sending message: %v", err)
return
}
// Read response from server
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, _, err := conn.ReadFromUDP(buffer)
if err != nil {
log.Printf("Error reading response: %v", err)
// For UDP, a timeout might just mean no response, not necessarily a fatal error.
continue
}
fmt.Printf("Server response: %s\n", string(buffer[:n]))
}
log.Println("Client disconnected.")
}
conn.ReadFromUDP
and conn.WriteToUDP
are used to specify the remote address for each datagram. There's no guarantee that a sent datagram will be received, or that responses will come from the expected sender.Leveraging Go's Concurrency for Network Applications
Go's concurrency model, built around goroutines and channels, is a game-changer for network programming. Instead of complex thread management or callback hell, you can simply prefix a function call with go
to run it concurrently as a lightweight goroutine. Channels provide a safe and idiomatic way for goroutines to communicate.
For network servers, this means each incoming connection can be handled by its own goroutine, allowing the server to remain responsive and handle many clients simultaneously. For clients, goroutines can be used to perform non-blocking I/O operations or manage multiple connections to different services.
sequenceDiagram participant Client participant Server participant Goroutine1 participant Goroutine2 Client->>Server: Connect (TCP) Server->>Server: net.Listen().Accept() Server->>Goroutine1: go handleConnection(conn1) Goroutine1->>Client: Read/Write Loop (conn1) Client->>Server: Connect (TCP) Server->>Server: net.Listen().Accept() Server->>Goroutine2: go handleConnection(conn2) Goroutine2->>Client: Read/Write Loop (conn2) Note over Server: Server continues accepting new connections Note over Goroutine1,Goroutine2: Each goroutine manages its own client connection independently
Concurrent TCP server handling multiple clients with goroutines
1. Run the TCP Server
Save the tcp_server.go
code and run it from your terminal: go run tcp_server.go
. You should see 'TCP Echo Server listening on :8080'.
2. Run the TCP Client
In a separate terminal, save the tcp_client.go
code and run it: go run tcp_client.go
. You should see 'Connected to TCP Echo Server on localhost:8080'.
3. Test TCP Communication
Type messages in the client terminal and press Enter. The server will log the received message and the client will display the echoed response. Type 'exit' to close the client.
4. Run the UDP Server
Save the udp_server.go
code and run it: go run udp_server.go
. You should see 'UDP Echo Server listening on :8081'.
5. Run the UDP Client
In another terminal, save the udp_client.go
code and run it: go run udp_client.go
. You should see 'Connected to UDP Echo Server on localhost:8081'.
6. Test UDP Communication
Type messages in the UDP client terminal. The server will log the message and the client will display the response. Note that UDP might not always guarantee immediate responses or delivery.