Go. 왜 빈 struct{}를 context.Value()의 키로 사용할까?
Go 빈 구조체의 의미와 활용 struct{}
What uses a type with empty struct has in Go?
What is the use of empty struct in GoLang
Empty struct
The Ingenious World of Empty Structs in Go: Zeroing in on Zero Memory
Decrypt Go: empty struct
ChatGPT
Go를 활용한 MQTT 프로젝트 진행 중, 종료 신호를 보내는 채널이 필요하여 찾아보던 도중
챗지피티의 도움을 받아 chan struct{}를 사용했더니, 코드가 잘 작동하였다.
struct 는 구조체로 알고 있는데, 구조체 타입의 채널? 이라고 생각하니 잘 감이 오지 않아
챗지피티의 도움을 받아 정리해보고자 한다.
chan struct{}는 빈 구조체(struct{}) 타입의 채널이다.
채널: 고루틴 간에 데이터를 주고받는 데 사용되는 도구
- 데이터를 전달하지 않고 단순히 신호만 전달하고 싶을 때
struct{}타입의 채널을 사용할 수 있다.
chan struct{} 의 신호 전달 방식
struct{}는 빈 구조체로, 메모리를 전혀 사용하지 않는다.chan struct{}를 통해 -> 고루틴 간에 종료 신호나 작업 완료 신호를 전달할 수 있다.struct 값 자체는 중요하지 않고, 채널이 닫히거나 값이 전송되는 것이 신호 역할을 한다.struct는 Go에서 가장 작은 데이터 타입으로, 빈 구조체이다.
// 일반 구조체 선언
person := struct {
Name string
Age int
}{"peter", 10}
struct는 메모리 사용량이 0인 데이터 타입, 메모리를 전혀 사용하지 않고도 값을 전달하거나 상태 표시 가능chan struct{}와 같은 형태로 채널 선언 시, 실제 데이터를 주고받지 않고 신호만 전달 가능. 이 경우, 채널에서 데이터를 꺼내는 작업은 struct{} 타입의 빈 값 하나만을 전달하여 신호 역할만 하도록 함map[string]struct{} 형태로 선언 시, 특정 키가 있는지 여부만을 확인하고 싶을 때 사용. 빈 구조체는 키에 대한 추가 정보를 저장할 필요가 없으므로 메모리 절약 가능
- An empty struct is still a struct, just with a size of 0.
- All empty structs share the same address: the address of zerobase.
- We can leverage the empty struct's non-memory-occupying feature to optimize code, such as using maps to implement sets and channels.
// 빈 구조체 테스트
test1 := struct{}{}
test2 := struct{}{}
fmt.Printf("빈 구조체1의 메모리 사이즈: %d\n", unsafe.Sizeof(test1)) // 메모리 크기 = 0 : 구조체 필드 없어서 메모리 차지 X
fmt.Printf("빈 구조체2의 메모리 사이즈: %d\n", unsafe.Sizeof(test2))
fmt.Printf("빈 구조체1의 메모리 주소: %v\n", unsafe.Pointer(&test1)) // 모든 빈 구조체가 동일한 주소 공유하도록 최적화 : 메모리 공간 점유 XW
fmt.Printf("빈 구조체2의 메모리 주소: %v\n", unsafe.Pointer(&test2))
stopChan := make(chan struct{})
이 경우, stopChan <- struct{}{}로 값을 보내거나, close(stopChan)을 호출하여 채널을 닫아 수신 측에서 종료 신호를 전달할 수 있다.
stopChan <- stopChan{}{}로 송신 (빈 값)<-stopChan로 신호 대기
chan struct{}는 데이터 없이 간단히 신호만 전달해야 할 때 사용되므로, 메모리 사용을 최소화하고 효율적으로 종료 신호를 주고받을 수 있다.
<- stopChan은 Go에서 채널에서 값을 수신할 때 사용하는 구문이다.
stopChan 채널로부터 값을 기다리는 역할<- stopChan의 역할
1. 수신 대기: <- stopChan는 stopChan 채널에서 값이 올 때까지 기다림
2. 동기화 및 신호: stopChan 채널은 종료 신호 전달에 사용됨
close(stopChan)이나 stopChan <- struct{}{}로 신호를 보내면, <- stopChan을 통해 이를 감지하고 실행을 종료하거나 특정 작업을 멈출 수 있다.사용 예시
stopChan := make(chan struct{})
// 다른 고루틴에서 종료 신호를 보내는 코드
go func() {
fmt.Println("작업 중...")
time.Sleep(2 * time.Second)
close(stopChan) // stopChan을 닫아 종료 신호를 보냄
}()
// stopChan에서 신호를 기다림
<-stopChan
fmt.Println("종료 신호를 받아 작업 종료")
위 코드에서 <- stopChan은 stopChan이 닫힐 때까지 대기한다.
close(stopChan)이 호출되면 <- stopChan이 작동하여 "종료 신호를 받아 작업 종료"를 출력하게 된다.
<-stopChan을 통해 프로그램의 특정 부분이 종료 조건을 기다리게 할 수 있다.발행자가 topic/exit이라는 주제로 메시지 "exit" 발행 시, 아래의 구독자 프로그램은 종료되도록 코드를 구성하였다.
func subscribeToMQTT(client mqtt.Client, topic string, id int, wg *sync.WaitGroup, results chan<- SubscriberResult, stopChan chan struct{}, once *sync.Once) {
...
// 발행자 종료 확인 주제 (topic/exit) 구독
exitTopic := topic + "/exit"
client.Subscribe(exitTopic, 2, func(_ mqtt.Client, msg mqtt.Message) {
if string(msg.Payload()) == "exit" {
fmt.Printf("-> Subscriber %d received exit message. Shutting down.\n", id)
// chan struct{}는 데이터 없이 간단히 신호만 전달해야 할 때 사용
once.Do(func() { close(stopChan) }) // 모든 구독자에게 종료 신호 전송 (한 번만)
}
})
...
}
: MQTT 메시지를 구독하면서, exit 메시지를 수신했을 때 stopChan 채널을 닫아 모든 구독자에게 프로그램 종료 신호를 보낸다.
1. sync.Once와 once.Do
sync.Once는 코드가 한 번만 실행되도록 보장하는 동기화 도구
Do 메서드를 동시에 호출해도, 첫 번째 호출에서만 함수가 실행되고 그 이후에는 무시된다.once.Do(func() { close(stopChan) })는 close(stopChan)을 여러 구독자가 동시에 호출하지 않도록 보장한다. exit 메시지를 받는 구독자가 여러 명일 수 있지만, 그중 한 명만 stopChan을 닫도록 하는 것.2. close(stopChan)
close(stopChan)은 stopChan 채널을 닫음
stopChan이 닫히면, <-stopChan을 기다리고 있는 모든 고루틴이 신호를 수신하고, 이를 통해 프로그램이 종료된다.close는 채널을 닫아도 값을 보내지 않지만, 수신 대기 중인 <-stopChan 구문은 채널이 닫힌 것을 감지하여 다음 작업으로 넘어가거나 종료를 준비할 수 있다.// MQTT 주제 구독 및 메시지 수신 핸들러 설정 (QoS=2)
client.Subscribe(topic, 2, func(_ mqtt.Client, msg mqtt.Message) {
select {
case <-stopChan: // stopChan이 닫히거나 값이 들어올 때
return
default:
payload := string(msg.Payload())
fmt.Printf("[Subscriber %d] Received from MQTT: %s\n", id, payload)
receivedCount++
}
})
<-stopChan // stopChan 채널에서 값을 수신할 때까지 대기
results <- SubscriberResult{
ID: id,
Receivedcnt: receivedCount,
Expectedcnt: expectedCount,
}
-> select 문은 각 메시지 수신마다 처음부터 반복되며, stopChan이 닫힐 때까지 계속해서 메시지를 처리
<- stopChan : "종료 신호를 기다린다"stopChan 채널에서 값을 수신할 때까지 대기stopChan 채널에서 값이 수신되거나, 채널이 닫힐 때까지 해당 구문은 블록(멈춤) 상태close(stopChan)이 호출되면, <- stopChan에서 대기 중이던 고루틴이 이 신호를 감지하고 다른 작업으로 넘어가거나 종료하게 됨case <- stopChan (select 구문 내)select 구문은 여러 채널을 동시에 감시 가능case <- stopChan은 select 블록 안에서 stopChan에서 신호가 오면 실행할 코드 정의하는 방식stopChan이 닫히거나 값이 들어올 때 해당 case 블록이 실행되도록 함
- 채널이 닫히면
<-stopChan은struct{}{}(zero value)를 반환- 이 반환을 통해 종료 신호로 인식하여,
case <-stopChan이 즉시 실행- 코드에서
<-stopChan이 있던 다른 위치들도 대기를 중단하고 다음 코드로 진행
즉, stopChan이 닫히면:
case <-stopChan이 즉시 실행되어 메시지 수신을 중단한다.
코드의 다른 <-stopChan도 대기를 중단하고 종료 작업을 진행하게 된다.
- stopChan이 닫히지 않으면:
- 수신한 메시지를
payload에 저장하고, 메시지를 출력한 후receivedCount를 증가시키면서 계속 메시지를 수신
- stopChan이 닫히면:
case <-stopChan이 실행되어return을 호출해 핸들러가 종료- 이후 핸들러 밖의
<-stopChan을 통해 구독 종료를 인식하고,results채널로 구독 결과를 전송
Go에서 채널을 닫는 것은 수신 측에 종료 신호를 전달하는 효과를 가진다.
채널을 닫으면 그 채널을 수신하는 모든 고루틴이 채널이 닫혔다는 사실을 감지하고, 이를 이용해 작업을 종료할 수 있다.
<채널을 닫으면 생기는 일>
chan int의 경우 기본값은 0close(chan))은 고루틴 간에 더 이상 작업을 수행하지 말라는 신호로 사용된다.<-stopChan을 사용하여 이 신호를 기다리다가, 채널이 닫히면 작업을 종료하게 된다.채널을 닫으면, 그 채널에서 값을 기다리던 모든 수신 측은 즉시 값을 수신(기본값 반환)하면서 채널이 닫힌 것을 감지한다.
- 닫힌 채널이 기본값을 반환하기 때문에 수신 측은 그 채널이 닫혔다는 사실을 감지