회사에서 지정된 API 설계 및 배치 프로세스를 작성한 회고록을 작성하고자 한다.

1. 배치 프로세스 

- 배치 프로세스 는 cms 서버에서 돌아간다. cms 서버에서 메모리 할당량이 넉넉해서 cms 서버에서 구축하게 되었고 하나의 고 루틴을 할당하고 ticker를 설정해 매 1분마다 신호를 보내 고 루틴이 실행될 수 있도록 작성하였다.

- 배치의 특성상 프로세스 내에 설정값들을 yaml 파일에 따로 뺴서 작성하고자 했으나 table에 설정하여  설정값에 대해 유연성을 두었다.

- 컨피그 에서 설정한 키값 "delete_cron"을 이용해 값을 가져오고 조회가 실패하고, record not found 에러가 생긴다면 테이블에 인서트를 수행하게 작성하였으나. (이 코드는 추후 버그가 발생할 수 있는 코드가 될 수 있다고 리뷰를 받게 되어 삭제했다.)

- 테이블 구성으로는

  • pk 값 "delete_cron 을 설정해 주고", Is_Use라는 필드를 넣어 사용되는 필드인지에 대해 추가를 했으나 실제 코드상에서 검증하는 부분이 없어 리뷰에서 검증하는 부분에 추가적인 코드작성 요청을 받았었다.
  • code_name을 넣어 이 컨피그 값이 어떤 역할을 하는지에 대해 명확하게 작성하기 위해 작성하였으며
  • code_value는 크론 값 자체를 스트링으로 넣어 배치 가 언제 돌아가는지에 대한 설정값을 추가하였다.
    (크론 [분 / 시간 / 일 / 월 / 요일 ] 총 5자리,  이며  각 자리는 정해진 범위가 존재하며 알맞게 사용되어야 한다. https://en.wikipedia.org/wiki/Cron)
  • 마지막 실행시간
  • Msg 마지막 실행 에서 몇 개의 라인이 삭제되었는지에 대해 히스토리를 남기기 위해 추가했다.

- 크론 의 값을 가져와 cron 라이브러리 의 cron.Schedlue 타입을 가져와 크론 값 parse를 수행한다.

- parse 에서 전해진 Job과 마지막 실행시간을 비교해서 마지막 실행시간 다음 job 배치 의 실행 시간 비교해서 함수 실행 여부를 판별한다.

- 실행 여부가 결정되면 정해진 배치 임무에 따라 배치 삭제를 진행하게 되고 배치 삭제에 대한 결과 로우와 마지막 수행 시간을 각각 업데이트해주면 된다.

 

예전 스프링배치 를 이용해 배치 프로세스를 작성했는데, 그때와는 사뭇 다른 배치 프로세스를 고 루틴을 이용해 이렇게 쉽게 작성하였다. 

물론 배치 인서트 작업이 배로 어렵다는것은 안다 추가적인 필드가 필요하고 실패지점에 대한 관리와, 다음 배치실행 시 실패지점으로 부터 시작하는 등의 추가적인 로직 작업이 필요하지만, 삭제 작업을 위와 같이 고 루틴을 이용해 손쉽게 작성할 수 있다는 점이 매력적인 것 같다.

 

2. API 설게

 

- 콘서트 의 특정 공연에 대해 관리 및 수정을 해야 하는 API를 개발해야 했다. 이에 따라 추가적인 테이블 설계를 하였는데 회사의 모든 테이블 은 연관관계 가 없다. 다시 말해 db 상의 연관관계가 존재하지 않으며, 코드상에 연관관계를 설정해 사용하고 있다. 

이러한 특성에 따라 복합키 의 사용이 생각보다 많이 있는데 어떤 테이블 에는 복합키 에 설정된 값과 중복해서 인덱스를 다시 설정해서 사용하는 게 아닌가? 복합키 설정에 있어서 순서가 매우 중요하다. 

예를 들어 "a,b,c,d" 를 이용해 복합키를 설정하게 된다면 b, b and c , b and d , c and d와 같이  조회를 한다면? 모두 all type으로 전체 로우를 조회하게 된다.

대신 a 를 포함한 where 조건절을 사용하게 된다면 range 혹은 const 타입으로 인덱스를 활용해 조회를 수행하게 된다.

MYSQL에서 복합키 설정을 첫 번째 열을 기준으로 인덱스가 생성되는 특징이 있는데 이를 고려하지 않고 테이블 설계, 쿼리를 작성하다 보니 나중에 실행계획서 작성할 때 인덱스 활용이 없는 경우가 발생되었고, 추가적인 인덱스 설정을 추가하게 되었다.

 

- 핵사고 날 아키텍처 가 적용된 프로젝트에 적응하기 가 너무 힘들었다. 각종 어댑터 가 존재해, 외부 시스템과 상호작용하게 된다. 도메인의 메인 비즈니스 로직이 외부요소에 의존하지 않도록 분리되어 있는데 이렇게 되다 보니 , 어느 패키지에서 어떤 역할을 할지 어떤 계층이 될지에 대해 고민하는 게 생각보다 많은 시간이 할애되었다.
spring-mvc 패턴, 레이어드 아키텍처에 적응되어 있는 나에게, 템플릿 패턴, 퍼사드 패턴 등등 디자인 패턴이 범벅되어버린 프로젝트 구조에 있어 코드를 해석하는 게 너무 힘들었다. 

인터페이스를 이용해 웬만한 호출들은 전부 추상화되어 있기 때문에 언제는 미들웨어의 인터페이스가 언제는 레포지토리의 인터페이스가  발생하기 에 어느 부분을 수정을 해야 하는지 어디에 추가하는지 이게 이벤트 발행은 가능한지에 대한 의문이 너무 많이 들었다.

 

모든 프로젝트가 동일한 패키지 구조와 코드 플로우를 가져가기에 하나의 프로세스 만 이해하게 되면 나머지는 그래도 이해하기 쉬웠다. 

메인 고 루틴 과 리퀘스트 발생에 따른 고루틴 생성과 서비스 핸들링, 카프카 설정에 따른 어드민 생성 호출 시 이벤트 발행과 같은 코드의 호출, 이벤트 컨슘에 따른 핸들링을 모두 작성하다 보니 고려해야 할 사항이 많았으며 

msa 가 적용되어 프로세스 가 각 서비스 별로 분리되어 있다. 이에 따라 공통으로 사용하는 부분을 common 패키지로  관리되고 이에 따라 common 패키지 구조체 설정이 생각보다 어려웠다. 

멀티모듈과 유사하게 common 패키지는 다른 모듈? 프로세스 컴파일 간에 같이 포함되게 된다. 따라서 가볍게 유지하는 것이 상식적으로 맞지 않겠는가? 이에 따라 고 의특성상 메서드 작성을 어디에 해야 할지에 너무 큰 고민이 되었다. toDomain? 이 된다면? 바뀌는 코드에서 가야 하는 코드에 대한  구조체에 작성하면 된다. 그런데 이 toDomain 이 다른 프로세스, 모듈에서 사용되는가? 그것은 또 아니다. 이렇게 되다 보니 common 패키지에 무엇을 작성해야 하는지에 대한 혼동이 온다. 

그 외에도 특정 이벤트 혹은 인터페이스 타입에 맞게 변경해야 하는 경우가 있다면? 팀 내부적인 특정 컨벤션이 존재하지 않다 보니, 중구난방으로 코드를 작성한 게 없지 않아 존재한다.

사수님 : "중복으로 사용하게 된다면 common에 작성하세요". 

중복으로 사용되지 않지만 함수의 의미상 common 에 위치해야 하는데 그게 아니기에 타입을 따로 받아서 새로운 타입을 만들어서 사용하게 되었는데. 참 아이러니하다.

여기서 고의 강타입 언어의 단점을 너무나도 크게 느껴졌다. db에서 가져오는 타입, 도메인으로 변경하는 타입, 이벤트 발행하는 타입에 대한 모든 변경 코드가 필요하게 되고 이에 따른 코드 작성이 어마어마하게 많다. (물론 코파일럿이 대부분 도와주었다.) 그렇다 보니 이벤트 발행에 항상 동일한 to 함수가 사용되는 것이 아니고 이에 따른 서로 다른 변환함수가 필요하게 된다. 

작성할 코드가 많고, 재사용 가능한 코드에 대해 고민을 많이 하게 되다 보니 생각보다 많은 시간을 소모했다.

 

- go 메서드 의 receiver method, value method 즉 포인터, 일반 함수라고 생각하면 된다. go uber 가이드에서 제공하는 방법으로는 하나의 방법으로 통일할 것을 권장하지만 아무래도 프로젝트에 참여한 사람이 생각보다 많다 보니 아무래도 혼합된 방법으로 구조체의 함수를 작성하게 되고 이에 따라 고 랜드에서 경고가 있는데 하나의 함수선언 방식으로 통일해 달라는 경고이다. 

이에 관해 사수 님께 여쭤보고, 이번 개발건에 대해서는 전부 receiver로 작성하게 되었다. 

 

- [에러처리]

dvt, cvt, qa 팀 의 모든 테스트를 통과했고 상용배포까지 마무리가 되었지만 갑자기 날아온 문의사항, 데이터가 삭제된다는 문의였다.

어드민 사용자 입장에서 api 호출에 대해 에러응답이 발생된다면 마치 데이터가 삭제된 것처럼 나오는데 그 현상인데 데이터가 삭제된다는 문의에 식은땀이 흘렀다. 팀장님 빠르게 로그를 검색하고, 에러의 원인을 파악하셔서 손쉽게 해결되었지만. 문제의 발생은 내가 작성한 코드에서 생겼다.  db 조회에 대한 err를 받고 그다음 로직에서 err에 대한 핸들링 없이, 코드 작성이 되어있다 보니 memory invalid exception으로 패닉으로 떨어진다.

비즈니스 로직에서 특정 칼럼 값은 항상 이벤트 발생으로 방송센터에서 넣어주는 값으로 알고 코딩을 해서 위와 같은 결과가 발생했지만, 에러에 대한 핸들링을 하지 않아서 크게 혼났다.

 

- [테스트 코드]

tdd를 적용할 시간조차 없었다. 단순 함수를 작성하고 이게 내가 원하는 쿼리가 발생하는지 에 대한 검증 정도만 있었지 tdd의 t 자조차 꺼낼 수가 없었다.. 그러다 보니 테스트 코드를 작성했어도 단순 확인하는 용도이기에 커밋을 전혀 하지 않았다.

프로젝트 종료 이후 세미나 간에 tdd 주제를 맡아 발표를 하게 되었고, tdd를 과연 저 프로젝트하는 동안 적용이 가능한가에 대한 나의 답은 NO이다.

저런 짧은 시간과, CDR 문서를 비롯한 각종 문서 지옥에서 tdd와 cdr 문서 작성 등  과 같은 중복된 일을 해야 하는가? 에 대한 의문이 생긴다. 

내가 작성하는 신규 api, function, module 은 테스트 할 수 있다. 그 코드에 대한 사이드 이펙트에 대해 어느 정도 예상을 하고 코드 작성을 하게 되니깐, 그런데 내가 작성한 것이 아닌 코드에 대해 테스트 코드를 작성하는 것은 정말 개발자 역량에 따른 천차만별이라고 생각한다. 

물론 코드를 보고 어떤 의도로 작성이 되었는지 어떤 문제점이 생길지에 대해 코드에 대한 테스트 코드는 작성할 수 있다. 고작 API 개발이니깐 그런데 이런 API 가 아닌 시스템 비즈니스 로직을 검증해야 한다면?????
또한 API 개발 간에도 문의사항으로 지속적인 로직 수정이 발생되었고, 이에 따른 테스트 코드 관리는 내가 너무 벅찬 부분이라고 생각되었다.

 

-[gorm]

정말 많은 삽질을 했다. transaction 관리를 db 호출할 때마다 개별적으로 해주어야 한다. 프로젝트 특성상 10개의 db 풀이 상주되고 있으며 상황에 따라 최대 20개까지 늘어날 수 있다. 

mysql 기본 격리 수준인 Repeatable Read에 따라 하나의 트랜잭션과 다른 트랜잭션 에서의 조회 결과는 다를 수가 있다. 따라서 nested transaction을 구성할 때 생각보다 조심해서 작성되어야 한다. 이 부분은 추후 포스팅에서 자세하게 다뤄보겠다.

 

 

 

 

'개발일지' 카테고리의 다른 글

FRP 적용  (0) 2024.12.31
10월 개발일지  (1) 2023.11.01
7월개발~ 8월초  (0) 2023.08.16
6월 개발  (1) 2023.07.09

4장부터 난이도가 급상승한다. 한줄한줄 읽는 게 고역인데 최대한 읽고 공부한 내용에 대해 정리해보고자 한다.

 

제한패턴

- 동시성코드 작 간 안전한 작동을 위한 몇가지 옵션이 존재한다.

  • 메모리 공유를 위한 동기화 기본요소 (sync.Mutex)
  • 통신을 통한 동기화(채널)
  • 변경 불가능한 데이터(const)
  • 제한 에 의해 보호되는 데이터

제한 은 하나의 동시 프로세스에서만 정보를 사용할 수 있도록 하는 간단한 아이디어이다.
이렇게 제한하게 된다면 임계영역 의 범위는 극한으로 작아질 것이며, 동기화 또한 필요하지 않다.

- 에드혹 , 어휘적이라는 두 가지 방식으로 가능하다.

 

애드혹 방법(제한패턴)

-근무하는 그룹, 작업하는 코드 베이스에 설정된 관례에 의해 제한이 이루어지는 경우이다.

func Test_Pattern_01(t *testing.T) {
	data := make([]int, 4)

	loopData := func(handleData chan<- int) {
		defer close(handleData)
		for i := range data {
			handleData <- data[i]
		}
	}

	handleData := make(chan int)
	go loopData(handleData)

	for num := range handleData {
		fmt.Println(num)
	}
}

정수 데이터 슬라이스는 오직 loopData를 통해서만 접근이 가능하다. 

 

그러나 이러한 방법에는 정적 분석 도구 가 반드시 필요하다, 가까워지는 마감시간 또는 많은 사람이 코드를 건드려 제한이 깨지는 문제가 발생할 수 있기 때문에 상당한 수준의 성숙도가 요구된다.

 

어휘적 제안

- 올바른 데이터만 노출하기 위한 어휘 범위 및 이를 사용하는 여러 동시 프로세스를 위한 동시성 기본 요소와 관련이 있다.

func Test_Pattern_02(t *testing.T) {
	chanOwner := func() <-chan int {
		results := make(chan int, 5)
		go func() {
			defer close(results)
			for i := 0; i <= 5; i++ {
				results <- i
			}
		}()
		return results
	}

	consumer := func(rs <-chan int) {
		for r := range rs {
			fmt.Println(r)
		}
		fmt.Println("Done")
	}

	rs := chanOwner()
	consumer(rs)
}

cahtowner 내부에서 채널을 생성성하고 핸들링한다.
이렇게 생성된 채널의 값은 consumer를 통해 소모되고 있는 고의 컴파일러를 활용한 채널의 특정 타입을 받아 활용하는 방법이다.

 

func Test_Pattern_03(t *testing.T) {
	printData := func(wg *sync.WaitGroup, data []byte) {
		defer wg.Done()
		var buff []byte
		for _, b := range data {
			buff = append(buff, b)
		}
		fmt.Println(string(buff))
	}

	var wg sync.WaitGroup
	wg.Add(2)
	data := []byte("golang")

	go printData(&wg, data[:3])
	go printData(&wg, data[3:])
	wg.Wait()
}

printData는 data와 같은 클로저 안에 존재하지 않는다.
data 슬라이스에 접근할 수 없으며, byte 코드를 인자로 받아야 한다.

고 루틴에 서로 다른 byte 값을 전달해 슬라이스 부분을 제한한다.
이렇게 된다면 메모리 동기화, 통신을 통한 데이터 공유가 필요 없다.

 

제한을 사용하게 된다면 개발자의 인지부하를 줄일 수 있으며(직관적인 코드해석), 동기화 의 비용을 지불할 필요가 없고 결과적으로 동기화로 인한 모든 문제에 있어 걱정할 필요가 없다는 장점이 있다.

 

for-select 루프 패턴

- Go 프로그램에서 정말 많이 만나볼 수 있는 패턴이다.

for _,s := range []string{"a","b","c"} {
	select{
    case <-done:
	    return
    case stringStream <- s:
    }
}

순회할 수 있는 값을 채널의 값으로 넘기는 방식이다. 이렇게 되면 매 loop 마다 stream으로 데이터 이동이 가능하다.

 

멈출 때까지 무한히 대기하는 방법

for{
	select{
    case <-done:
    return
    default:
    // 무언가의 다른 로직 수행
}

 

done 채널이 닫히지 않는다면 이 for 루프는 무한히 디폴트 블록을 수행한다.

 

고 루틴 누수 방지

- 고 루틴은 자원을 필요로 하며, 가비지컬렉터에 의해 수거되지 않는다.

 

고 루틴이 종료되는 경로

  • 작업이 완료되었을 때
  • 복구할 수 없는 에러로 인해 더 이상 작업을 계속할 수 없을 때
  • 작업을 중단하라는 요청을 받았을 때

처음 2 경우는 사용자의 의도에 따라 별다른 노력 없이 도달할 수 있다.
작업중단, 취소는 부모 고 루틴 은 그 자식 고 루틴에게 종료라고 말할 수 있어야 한다.
(5장에서 대규모 고 루틴의 상호의존성에 대해 언급한다고 하니 이러한 사실만 알고 넘어가자.)

 

고루틴의 누수

dowork := func(strings <-chan string)<-chan interface{} {
	completed := make(chan interface{])
    go func(){
    	defer fmt.Println("dowork exited.")
        defer close(completed)
        for s:= strings {
        	//작업
            fmt.Println(s)
        }
    }()
    return completed
}

dowork(nil)
fmt.Println("Doen".)

nil 채널을 전달하고 고 루틴은 계속 대기하면서 잔여하고 있다. 실제로 돌리면 데드락 이 터진다.

이 예제의 경우 단순한 프로세스이지만 실제 프로그램에서는 평생 동안 고 루틴들을 계속 돌려 메모리 사용량에 영향을 미칠 수도 있다.

이에 대한 보편적인 방법으로 는 취소 신호를 보내는 것이다.

 

func Test_Pattern_05(t *testing.T) {
	dowork := func(done <-chan bool, strings <-chan string) <-chan interface{} {
		terminated := make(chan interface{})
		go func() {
			defer fmt.Println("dowork exited")
			//defer close(terminated)
			for {
				select {
				case s := <-strings:
					fmt.Printf("Received : %s\n", s)
				case <-done:
					return
				}
			}
		}()
		return terminated
	}

	done := make(chan bool)
	terminated := dowork(done, nil)
	go func() {
		time.Sleep(1 * time.Second)
		fmt.Println("Canceling dowork goroutine...")
		close(done)
	}()

	<-terminated
	fmt.Println("Done.")
}

Canceling dowork goroutine...
dowork exited
Done.

strings 채널에 nil을 전달했음에도 불구하고 정상적으로 채널이 종료된다.
두 개의 고 루틴을 조인하지만 두개의 고루틴 을 조인하기전에 세번째 고루틴을 생성해 고루틴을 취소하고, 고 루틴의 종료와 동시 생성된 채널 또한 닫히게 되면서 성공적으로 종료된다.

 

func Test_Pattern_06(t *testing.T) {
	newRandStream := func() <-chan int {
		randStream := make(chan int)
		go func() {
			defer fmt.Println("newRandStream closure exited.")
			defer close(randStream)
			for {
				randStream <- rand.Int()
			}
		}()
		return randStream
	}

	randStream := newRandStream()
	fmt.Println("3 random ints :")
	for i := 0; i <= 3; i++ {
		fmt.Printf("%d : %d\n", i, <-randStream)
	}
}

3 random ints :
0 : 5147430441413655719
1 : 6491750234874133122
2 : 6757866054699588537
3 : 7924123138951490668
--- PASS: Test_Pattern_06 (0.00s)

defer의 커맨드라인 출력이 실행되지 않는 것을 알 수 있다. 

루프의 네 번째 반복이 진행된 메인 고 루틴이 종료됨과 동시에 서브 고루틴이 끝난다.

즉 저 고 루틴은 종료되지 않는다는 의미이다.

 

이것에 대한 해결책도 동일하다. 완료 신호를 추가적으로 보내주는 방법이다.

func Test_Pattern_06(t *testing.T) {
	newRandStream := func(done <-chan interface{}) <-chan int {
		randStream := make(chan int)
		go func() {
			defer fmt.Println("newRandStream closure exited.")
			defer close(randStream)
			for {
            	select{
                case randStream <- rand.Int():
          		case <-done:
                return
                }
			}
		}()
		return randStream
	}
    
	done := make(chan interface{})
    
	randStream := newRandStream(done)
	fmt.Println("3 random ints :")
	for i := 0; i <= 3; i++ {
		fmt.Printf("%d : %d\n", i, <-randStream)
	}
	close(done)
    time.Sleep(1*time.Sleep)
}

=== RUN   Test_Pattern_06
3 random ints :
0 : 2938542351934954817
1 : 3845534947550450275
2 : 737139443622443070
3 : 7227537810142655543
newRandStream closure exited.
--- PASS: Test_Pattern_06 (1.00s)

마지막에 time.Sleep()을 사용한 이유는 defer의 실행할 시간적 여유를 부여하는 것이다.

이게 없다면 main 고 루틴이 더 빠르게 종료되어 출력을 확인할 수 없다.

 

Or 채널

- 하나 이상의 done 채널을 하나의 done 채널로 결합해, 하나의 채널이 닫힐 때 모두 닫힐 수 있도록 결합하는 경우이다.

func Test_Pattern_07(t *testing.T) {
	// 1개 이상의 채널들을 보낼수 있다 <- 보내는 애들로만
	var or func(channels ...<-chan interface{}) <-chan interface{}
	or = func(channels ...<-chan interface{}) <-chan interface{} {
    	// 재귀의 탈출 조건
		switch len(channels) {
		case 0:
			return nil
		case 1:
			return channels[0]
		}

		orDone := make(chan interface{})

		go func() {
			defer close(orDone)
			switch len(channels) {
			case 2:
				select {
				case <-channels[0]:
				case <-channels[1]:
				}
			default:
				select {
				case <-channels[0]:
				case <-channels[1]:
				case <-channels[2]:
				case <-or(append(channels[3:], orDone)...):
				}
			}
		}()
		return orDone
	}
}

2개 이상의 채널에 대해 재귀 호출을 하는 모습이다.

이렇게 된 코드는 어느 하나의 채널에서 신호가 오게 된다면 즉시 select 문에서 벗어나 고 orDone 채널을 닫아버리는 함수이다.

	sig := func(after time.Duration) <-chan interface{} {
		c := make(chan interface{})
		go func() {
			defer close(c)
			time.Sleep(after)
		}()
		return c
	}

	start := time.Now()
	<-or(
		sig(2*time.Hour),
		sig(5*time.Minute),
		sig(1*time.Second),
		sig(1*time.Hour),
		sig(1*time.Minute),
	)

	fmt.Printf("done after %v", time.Since(start))

이런 방식으로 사용한다면 서로 다른 시간으로 채널을 닫더라도 1초 호출의 닫힘으로 인해 모든 채널이 종료된다.

 

에러처리

- 에러처리의 책임자는 누구인가? 누가 이를 책임지는가에 대해서 고다운 방식으로 처리하는 경우이다.

func Test_Pattern_08(t *testing.T) {
	checkStatus := func(
		done <-chan interface{},
		urls ...string,
	) <-chan *http.Response {
		response := make(chan *http.Response)
		go func() {
			defer close(response)
			for _, url := range urls {
				resp, err := http.Get(url)
				if err != nil {
					log.Fatal(err)
					continue
				}
				select {
				case <-done:
					return
				case response <- resp:
				}
			}
		}()
		return response
	}

	done := make(chan interface{})
	defer close(done)

	urls := []string{"https://www.google.com", "https://www.naver.com", "http://localhost:8080"}
	for response := range checkStatus(done, urls...) {
		fmt.Printf("Response : %v\n", response.Status)
	}
}

=== RUN   Test_Pattern_08
Response : 200 OK
Response : 200 OK
Response : 404 Not Found
--- PASS: Test_Pattern_08 (0.31s)
PASS

언뜻 살펴보기에는 별달리 문제가 없어 보인다. 그저 에러를 출력하고 누군가가 확인해 주길 바라는 코드가 되어버린다.

일반적으로 동시 실행되는 프로세스 들은 프로글매의 상태에 대해 완전하 정보를 가지고 있는 프로글매의 다른 부분으로 에러를 보내야 하며, 그래야 보다 많은 정보를 바탕으로 무엇을 해야 할지 결정할 수 있다. 

 

func Test_Pattern_09(t *testing.T) {
	type Result struct {
		Error    error
		Response *http.Response
	}

	checkStatus := func(done <-chan interface{}, urls ...string) <-chan Result {
		results := make(chan Result)
		go func() {
			defer close(results)
			for _, url := range urls {
				resp, err := http.Get(url)
				rs := Result{Error: err, Response: resp}
				select {
				case <-done:
					return
				case results <- rs:
				}
			}
		}()
		return results
	}

	done := make(chan interface{})
	defer close(done)
	
    urls := []string{"https://www.google.com", "https://www.naver.com", "http://localhost:8080"}
    for result := range checkStatus(done, urls...) {
        if result.Error != nil {
            fmt.Printf("Error : %v\n", result.Error)
            continue
        }
        fmt.Printf("Response : %v\n", result.Response.Status)
    }
}

동일한 결과를 콘솔에서 확인할 수 있지만 코드 적으로 변화가 상당하다.
함수를 호출하는 메인 고루틴 에서 이를 핸들링 할수 있다.
다시말해 메인 고루틴은 함수에서 반환된 에러 값에 의해 본인의 의사결정을 할수 있다는 의미이다. 

 

	urls = []string{"https://www.google.com", "https://www.naver.com", "http://localhost:8080", "a", "b", "c", "d", "e", "f", "g"}
	errCount := 0
	for result := range checkStatus(done, urls...) {
		if result.Error != nil {
			fmt.Printf("Error : %v\n", result.Error)
			errCount++
			if errCount >= 3 {
				fmt.Println("Too many errors, breaking!")
				break
			}
			continue
		}
		fmt.Printf("Response : %v\n", result.Response.Status)
	}
    
    
    Response : 200 OK
Response : 200 OK
Response : 404 Not Found
Error : Get "a": unsupported protocol scheme ""
Error : Get "b": unsupported protocol scheme ""
Error : Get "c": unsupported protocol scheme ""
Too many errors, breaking!

이렇게 에러에 숫자를 카운트해서 보다 유기적으로 에러를 처리할 수 있다. 

"함수의 호출자에서 에러를 처리한다" Go의 에러 처리 패턴이다.

고 루틴 들에서 리턴될 값을 구성할 때 에러가 일급 객체로 간주돼야 한다는 것이다.
고 루틴이 에러를 발생시킬 수 있는 경우, 이러한 에러는 결과 타입과 밀접하게 결합돼야 한다.

 

일급 객체 란 
모든 일급 객체는 변수나 데이터에 담을 수 있어야 한다.
모든 일급 객체는 함수의 파라미터로 전달할 수 있어야 한다.
모든 일급 객체는 함수의 리턴값으로 사용할 수 있어야 한다.


2편에서 계속..

지난번 싱크 패키지에 이어 고 루틴과의 환상의 조합인 채널에 대해 작성하고자 한다.

채널

-호어 의 CSP에서 파생된 GO의 동기화 기본 요소 중 하나이다. 

-채널은 고루틴 간의 데이터들 전달할 때 유용하게 사용된다.

-chan 변수에 값을전달하고 , 프로그램 어딘가에서 이 값을 읽어 들이면 된다.

var dataStream chan interface{}
dataStream = make(chan interface{})

이 외에도 단방향 채널도  설정할수 있다.

var dataStream <-chan interface{}
dataStream = make(chan<- interface{})

var dataStream2 chan<- interface{}
dataStream2 = make(<-chan interface{})

고에서는 양방향 채널이 필요에 따라 단방향 채널로 변환하기 때문에 필요에 맞게 양방향 선언 후 함수 리턴 값 혹은 인자값으로 타입을 할당해서 사용하자.

 

stringStream := make(chan string)

go func(){
	stringStream <- "채널로 부터 ~"
 }()
 
 fmt.Println(<-stringStream)

채널 변수만 있다면 그곳으로 데이터를 보내고 받을수가 있다. 

이 코드를 실행하면 "채널로 부터~" 의 프린트 값이 찍히는 것을 볼 수 있다.

 

지난 포스팅 에서 언급하기로는 고 루틴이 스케쥴링되었어도 실행될 수 있다는 보장이 없는데 어떻게 가능한 것인가? 에 대해 의문을 품을 수 있다.

보통 Go 의 채널은 멈춰서 기다린다. 즉

- 가득찬 채널에 쓰려고 하면 고 루틴은 채널이 비워질 때까지 기다린다.

- 비어있는 채널에서 읽으려고 하면 고루틴은 채널이 채워질 때까지 기다린다.

이러한 특성덕에 메인고루틴 과 익명 고 루틴 블록은 확실하게 대기하고 실행된다.

 

stringStream := make(chan string)

go func(){
	if true {
	      return
    }
	stringStream <- "채널로 부터 ~"
 }()
 
 fmt.Println(<-stringStream)

이러한 코드가 있다면 채널 은 값이 채워질수 없게 되고, 코드의 마지막 라인은 대기하다가 모든 고 루틴이 대기만 하는 데드락이 발생하게 된다.

 

<- 이 연산자에는 두 개의 반환값을 수신할 수 있는데

stringStream := make(chan string)

go func(){
	stringStream <- "채널로 부터 ~"
 }()
 
 rs,ok := <- stringStream
 
 fmt.Printf("%+v : %+v",rs,ok)

rs는 받아온 데이터를, ok는 닫힌채널이 생성하는 기본값인지 를 나타내기 위해 사용된다.

 

닫힌채널 이란 무엇인가?

-닫힌채널 이란 더 이상 값이 채널을 통해 전송되지 않는다는 것을 나타내는 채널의 상태를 의미한다.

intStream := make(chan int)
close(intStream)

value,ok := <-intStream
fmt.Println("value , ok",value,ok)

닫힌채널에서도 값을 읽을 수가 있다. 닫힌 채널에 대해서 여전히 읽기 작업을 수행할 수 있다.

이는 새로운 패러다임을 알려주는데 한 채널에 대해 range를 이용한 방법이다.

intStream := make(chan int)
go func(){
	defer close(intStream)
    
    for int i=1;i<=5;i++{
    	intStream <- i
    }
}()

for i := range intStream {
	fmt.Println(i)
}

채널이 닫힐 때 자동으로 루프를 종료하는 것을 이용해, 간결하게 채널의 값을 반복할 수 있다.

루프의 종료조건이 필요하지 않으며 해당 채널에서는 두 번째 부울값을 리턴하지 않는다.

 

"n 번의 쓰기를 통해 대기하는 고 루틴에게 신호를 전달하는 것보다 채널읠 닫는 것으로 빠르고 부하가 적게 고 루틴들에게 신호를 줄 수 있다."

 

begin := make(chan interface{})

var wg sync.WaitGroup

for i:=0;i<5;i++{
	wg.Add(1)
    go func(i int){
    	defer wg.Done()
    	<-begin
        fmt.Println("Has Begun ",i)
    }(i)
}

fmt.Println("UnBlocking go routines")
close(begin)
wg.Wait()

<- begin에서 모든 고 루틴이 대기한다 왜? chan에 값이 가득 차 있고 어느 곳에서도 읽지 않기 때문이다. 

이후 close(begin)을 통해 채널을 닫게 되면 모든 고 루틴들이 대기상태에서 벗어나 다음 라인을 수행한다.

Unblocking goroutines...
4 has begun
2 has begun
3 has begun
1 has begun
0 has begun

 

 

버퍼링 된 채널이라는 채널의 용량을 지정하는 방법이 있다. 읽기가 전혀 수행되지 않더라도 고 루틴은 지정한 용량만큼 쓰기를 수행할 수 있다.

 

func Test_BufferedCh(t *testing.T) {
	var strdoutBuff bytes.Buffer
	defer strdoutBuff.WriteTo(os.Stdout)

	intStream := make(chan int, 4)
	go func() {
		defer close(intStream)
		defer fmt.Fprintln(&strdoutBuff, "Producer Done")

		for i := 0; i < 4; i++ {
			fmt.Fprintf(&strdoutBuff, "Sending : %d\n", i)
			intStream <- i
		}
	}()

	for i := range intStream {
		fmt.Fprintf(&strdoutBuff, "Received %v\n", i)
	}
}

Sending : 0
Sending : 1
Sending : 2
Sending : 3
Producer Done
Received 0
Received 1
Received 2
Received 3

 

익명의 고 루틴이 4개의 결과를 모두 넣을수 있고, main 고루틴이 그 결과를 읽어가기 전에 루틴을 종료할 수 있다.

 

채널의 올바른 상황에 배치하기 위해 가장 먼저 해야 할 일은 채널의 소유권을 할당하는 것이다.

채널의 소유자는 

- 채널을 인스턴스화한다.

- 쓰기를 수행하거나 다른 고 루틴으로 소유권을 넘긴다.

- 채널을 닫는다.

 

func Test_Channel_Owner(t *testing.T) {
	chatOwner := func() <-chan int {
		resultStream := make(chan int, 5)
		go func() {
			defer close(resultStream)
			for i := 0; i <= 5; i++ {
				resultStream <- i
			}
		}()
		return resultStream
	}

	resultStream := chatOwner()
	for rs := range resultStream {
		fmt.Println(rs)
	}

	fmt.Println("Done Receiving!")
}

 

resultStream의 생명주기는 chatOwner 함수 내에 캡슐화되어있으며 닫기는 언제나 한번 동작한다는 것은 매우 분명하다.

이에 따라 소비자 함수는 읽기 채널이 차단되었을 때의 행동방법, 채널

 

Select

select는 채널을 하나로 묶는 접착제이다.

지역적으로 채널들을 바인딩하고, 두 개 이상의 구성 요소가 교차하는 곳에서도 전역으로 바인딩하는 것을 볼 수 있다. 

func Test_Select_01(t *testing.T) {
	var c1, c2 <-chan interface{}
	var c3 chan<- interface{}

	select {
	case <-c1:
		fmt.Println("c1")
	case <-c2:
		fmt.Println("c2")
	case c3 <- struct{}{}:
		fmt.Println("c3")
	}
}

switch 문과 유사해 보이지만, select는 채널 중 하나가 준비되었는지 확인하기 위해 모든 채널 읽기와 쓰기를 모두 고려한다.

준비된 채널이 없다면 select 문은 중단되어 대기한다. 

func Test_Select_02(t *testing.T) {
	start := time.Now()

	c := make(chan interface{})
	go func() {
		time.Sleep(5 * time.Second)
		close(c)
	}()

	fmt.Println("Blocking on read...")

	select {
	case <-c:
		fmt.Printf("Unblocked %v later.", time.Since(start))
	}
}

Blocking on read...
Unblocked 5.001061625s later.--- PASS: Test_Select_02 (5.00s)

go 루틴과 메인이 실행되면서 go 루틴은 5초간 대기하고 메인루틴은 select에서 대기한다.

이후 5초가 지난 후에 close 채널이 되면서 채널의 신호가 가고 select의 case 가 신호에 응답한다.

 

func Test_Multiple_Channel(t *testing.T) {
	c1 := make(chan interface{})
	close(c1)
	c2 := make(chan interface{})
	close(c2)

	var c1Count, c2Count int
	for i := 1000; i >= 0; i-- {
		select {
		case <-c1:
			c1Count++
		case <-c2:
			c2Count++
		}
	}
	fmt.Printf("c1Count : %d\nc2Count : %d\n", c1Count, c2Count)
}

c1Count : 501
c2Count : 500

두 개의 채널을 생성하자마자 바로 닫아버리고 select 문을 이용해서 채널로부터 zero 값을 계속 동시에 읽어온다면 결과는 약 반반 씩 실행된다. select-case 구문에서는 균일한 의사 무작위 선택을 수행한다. 

 

어떠한 채널도 준비되어 있지 않은 경우 그동안 무엇을 해야 하는지에 대해 select 문은 default를 제공한다.

func Test_Select_03(t *testing.T) {
	start := time.Now()

	var c1, c2 <-chan int

	select {
	case <-c1:
	case <-c2:
	default:
		fmt.Println("In default after ", time.Since(start))
	}
}

In default after 이 즉시 실행된다. 이렇게 작성하면 select 블록을 빠져나올 수 있다.

 

func Test_Select_04(t *testing.T) {
	done := make(chan interface{})
	go func() {
		time.Sleep(5 * time.Second)
		close(done)
	}()

	workCounter := 0
loop:
	for {
		select {
		case <-done:
			break loop
		default:
		}
		workCounter++
		time.Sleep(1 * time.Second)
	}

	fmt.Printf("Achieved %v cycles of work before signalled to stop.\n", workCounter)
}

select 문은 위와 같이 for {} 루프와 같이 사용하게 되면 보다 효율적으로 채널들을 핸들링할 수 있다.

5번의 작업이 실행되고 5초 후 채널이 닫히면서 for 문은 종료된다.

 

1. 고루틴

-  고프로그램을 구성하는 가장 기본적인 단위 중 하나이다. GO 코드의 시작점인 main 함수 또한 고루틴 으로 할당되어 실행된다.
- 고루틴은 다른 코드와 함께 동시에 실행되는 함수라고 이해하면 쉽다.

사용방법은 Go Tour에서 확인 바란다. 아래 간단한 예제를 첨부한다.

func hello(){
	fmt.Println("안녕 잘지내?")
}
func main(){
	go hello()
	//익명함수 방식
    func(){
      fmt.Println("응 잘지내")
    }()
}

고루틴은 OS 스레드 인가? 그린 스레드 인가?라는 주제를 시작으로 이야기를 풀어간다. 

OS 스레드 란?

-OS 작업에 의해서 관리되는 스레드를 OS 스레드 라고 한다. 개별적인 스택, 레지스터 상태를 가지고 있으며, 커널이 스케쥴링을 하며 이는 CPU 코어 간의 부한 분산을 가능하게 합니다. 그러나 이로 인해 스레드 간의 교체에 따른 오버헤드를 컨택스트 스위치라고 일컬으며 이 비용이 높다는 단점이 존재한다.

 

그린 스레드란?

-그린 스레드란 프로그램 또는 런타임 시스템이 관리하는 가벼운 스레드로, 가상 스레드라고도 한다. OS 스레드 보다 생성과 관리비용이 훨씬 적지만 한 번에 하나의 OS 스레드에서만 실행될 수 있습니다.

 

고 루틴은 이 둘 중에 어디에도 분류할 수 없다. 그린스레드 보다 높은 수준의 추상화인 코루틴이다. 

코루틴 이란?

- 코루틴은 단순히 동시에 실행되는 서브루틴(함수, 클로져, 메서드) 로서 , 비선점적이다. 다시 말해 인터럽트 할 수 없다는 뜻이다. 이런 특성에 따라 코루틴은 2가지 특징이 있는데  중단과 재개 의 특징이 존재한다. 자신의 실행을 일시적으로 중단하고, 필요에 따라 다시 재개할 수 있다. 

 

아무리 이런 동시기능을 제공해 주는 기능이 있더라도 누군가는 이 동시 기능이 가능하게 임무분담을 해주어 햐는데 GO에서는 이 메커니즘을 M:N 스케쥴러라고 한다. 

가용한 그린스레드 보다, 많은 고 루틴이 존재한다면 스케쥴러는 사용가능한 스레드들로 고루틴을 재분배 하고 , 이고루틴이 대기상태가 되면 다른 고루틴이 실행될 수 있도록 한다.

M개의 그린스레드를 N 개의 OS 스레드에 맵핑한다는 의미이다. 프로세스 단위에서 스레드로 맵핑이 올라간다고 생각하면 생각보다 범위가 너무 넓어진다.그린스레드와 OS 스레드 중간 어딘가의 추상화 단계 에서 그린스레드 의 고 루틴 이 넘치면 이에 스레드가 할당되어 고루틴이 비선점적으로 실행된다 ? 스레드 와 고루틴 간의 통신 수단은 무엇이 될지 어떻게 진행될지 , 이들 간의 스위칭에 어떤 문제가 존재하는지 정말 많은 의문이 꼬리에 꼬리를 물지만 이에 대해 6장에서 보다 자세히 설명한다고 하니 그냥 이런 게 있나 보다 하고 넘어가자.

M 은 OS 스레드에 의해 할당되는 쓰레드의 모습을 표현 하였다. 이에 OS 쓰레드 하나에는 모든 P  가 붙게 되고 이는 하나의 메인 고 루틴 이 존재하고,
P 안에는 로컬큐 고루 틴들이 할당된다. 
M 은 단지 OS에 의해 할당받고 실행된다면 고 루틴은 이 M에 의해 실행이 된다고 이해하면된다.(https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html)


Go에서는 Folk-Join 모델을 따른다.

동시성 모델 중 하나로, 프로그램 어느 지점에서든 자식 분기를 만들어 미래 어느 시점에 다시 합쳐진다.

고에서는 단순히 go 키워드를 이용하면 위 표에서 포이는 포크의 한지점이 생성되는 것이다. 고 키워드 로직이 마무리가 된다면 join 지점에 의해 메인 고 루틴에 합류된다.

 

위에서 코루틴의 범위중 하나에 클로저라는 단어를 언급했다. 클로저 란 함수 의 실행 컨택스트 내에서 함수 실행환경을 캡처하여 함수호출시 동일한 환경을 제공해 준다. 아래 예제를 보자.

var wg sync.WaitGroup
greeting := "안녕 잘지내"
wg.Add(1)
go func(){
 defer wg.Done()
 greeting = "응 잘지내"
}()
wg.Wait()

fmt.Println(greeting)

이는 "응 잘 지내"를 반환한다. 고 루틴 은 자신이 생성된 곳과 동일한 주소 공간에서 실행되기 때문에, 가능한 일이다. 

 

var wg sync.WaitGroup
for _,a := range []string{"ㄱ","ㄴ","ㄷ","ㄹ"} {
	wg.Add(1)
    go func(){
    	defer wg.Done()
     	fmt.Printnl(a)
    }()
}
wg.Wait()

이 코드의 결괏값으로는 ㄹ,ㄹ,ㄹ,ㄹ 이 출력된다. 왜?

for 루프가 돌 때마다 a의 값을 가져가 사용하기 위해 클로저가 캡처된다고 생각해 보자. 그러나 스케쥴링된 고 루틴이 어느 시점에 실행될지 알 수가 없다. 미래의 어느 시점에든지 실행될 수 있기 때문에 어떤 값이 출력될지 정해져 있지 않다.

고 루틴이 실행되기 전에? for 루프가 종료될 확률이 높다는 의미이다. 

그렇게 된다면 루프의 범위를 벗어난다면 a는 어디서 참조를 해오는 것인가? 저 a의 변수가 고 루틴에서 참조가 가능하도록 힙 공간으로 옮겨진 것이다. 

함수의 인자값으로 던져줘서 복사를 시키면 원하는 결괏값을 반환하게 된다.

 

이는 다시 말해 고 루틴은 동일한 주소 공간에서 작동하며 단순 함수를 호스팅하는것 이것을 기억하고 넘어가자

(버려진 고루틴은 가비지 컬렉터에 의해 회수되지 않는다. 이에 대하여 4장에서 언급한다고 하니 인지만 하자)

 

고 루틴이 얼마나 가벼운지 에 대하여 설명하는데  이론상 8기가 램에 수백만개의 고루틴 생성이 가능하다.

 

여기서 이론상이라는 말이 붙은 이유는 콘텍스트 스위칭 이 라는 개념이 존재하기 때문이다. 

전환하기 위해 자신의 상태를 저장하고, 변경되는 프로세스의 상태를 불러오는 것을 말한다.

프로세스 가 너무 많아 프로세스 사이의 컨텍스트 스위칭에 모든 CPU 시간을 소모하느라 작업 수행이 불가능할 수 있으며,

OS 스레드를 사용하면 스위칭으로 인한 비용 발생에 따른 성능저하 가 존재한다. 

 

반면 소프트웨어 안에서의 스위칭의 비용은 상당히 저렴하다.

 

즉 위 절에서는 고 루틴으로 인한 성능문제 가 발생된다면? 명확하게 고 루틴으로 인한 성능 문제라는 사실이 밝혀졌을 때 그 비용을 논의해야 한다고 주장한다.

 

2. Sync 패키지

 go의 Sync 패키지 에는 저수준 메모리 동기화에 유용한 동시성 기본 요소들이 포함되어 있다. 

 

2-1  WaitGroup

동시에 수행될 연산의 집합을 기다릴 때 유용하다. 아래 예제를 보자.

var wg sync.WaitGroup

wg.Add(1)

go func(){
	defer wg.Done()
    fmt.Println("1st Go Routine is sleeping")
    time.Sleep(1)
}()

wg.Add(1)

go func(){
	defer wg.Done()
    fmt.Println("2nd Go Routine is sleeping")
    time.Sleep(1)
}()

wg.Wait()
fmt.Println("All go routines are done")

2-2 Mutex와 RWMutex

Mutex는 상호배제 의 약자로 , 프로그램 의 임계영역을 보호하는 방법이다. 

GO 답게 말한다면, 채널은 메모리를 통해 공유하는 반면, Mutex는 개발자가 메모리에 접근하는 방법을 통제하는 규칙을 만들어 메모리를 공유한다.

 

func Test_Mutex(t *testing.T) {
	var count int
	var lock sync.Mutex

	increment := func() {
		defer lock.Unlock()

		lock.Lock()
		count++
		fmt.Println("Added Count current is : ",count)
	}
	decrement := func() {
		defer lock.Unlock()
		
		lock.Lock()
		count--
		fmt.Println("Sub Count current is : ",count)
	}
	
	var proc sync.WaitGroup
	for i:=0;i<5;i++{
		proc.Add(1)
		go func(){
			defer proc.Done()
			increment()
		}()
	}
	
	for i:=0;i<5;i++{
		proc.Add(1)
		go func(){
			defer proc.Done()
			decrement()
		}()
	}
	
	proc.Wait()
	
	fmt.Println("All Tasks done")
}

mutex의 구조체를 이용해서 함수의 호출 전후로 lock과 unlock을 진행한다. (자바의 syncronized 키워드 가 어떻게 동작하는지 어림잡아 추측이 가지 않는가?)

단순하게 unlock을 하지 않는다면? 프로그램은 deadlock에 빠지고 panic 이 떨어져 프로세스는 떨어지게 된다.

 

이러한 임계영역의 진입 은 다소 비용이 많이 들기에 최소화하기 위한 노력을 많이 한다.  이 결과의 산출물이 

RwMutex이다. 임계영역의 범위를 줄이는 방법이다. read, write를 구분하는 것이다 항상 모든 스레드에서 쓰기를 동반한 모든 작업이 필요하지 않는 경우가 존재하지 않겠는가? 에서 출발한 아이디어이다.

 

읽기 잠금 요청을 할 수 있지만, 다른 프로세스에서 쓰기 권한을 가지지 않은 경우에만 가능한다 던 지, 아무도 쓰기 잠금을 보유하지 않고 있다면, 여러 개의 포르세스에서 읽기 잠금을 보유할 수 있다. 논리적으로 합당하다면 이런 경우에는 RWMutex를 사용하는 것이 합당하다.

 

2-3 Cond

고 루틴들이 대기하거나 , 어떤 이벤트의 발생을 알리는 집결 지점 (https://pkg.go.dev/sync#Cond).

두 개이상의 고 루틴 사이에서, 어떤 것이 발생했다는 사실에 대한 임의의 신호를 이벤트라고 일컫는다. 고 루틴의 실행 전 이러한 신호들 중 하나를 기다리고 싶을 수도 있는데 cond 타입이 이를 도와준다. 

cond 타입 없이 나이브하게 구현을 해본다면

 

무한루프를 사용하는 것이다. 

이렇게 되면 코어의 모든 사이클을 소모하기에 매우 비효율적이다. 이를 개선하기 위해 time.sleep을 이용해 강제로 스위칭을 일으킬 수 있다. 이 방법이 무한루프 보다 조금 더 낫지만 여전히 비효율적이다. 어느 정도의 슬립 이 필요한지 모르며 또한 이 슬립이 길어진다면 성능의 저하로 직결되기 때문이다.
고 루틴이 신호를 받을 때까지 슬립하고 있으며, 자신의 상태를 확인할 수 있는 방법 = cond 타입이 해주는 일이다. 

cond의 L locker 를 이용해 L.Lock, L.Unlock 을 활용이 가능하며 cond 의 자체적인 메서드 인 wait을 이용해 고 루틴을 일시중지 시킬 수 있다.

wait 은 단지 고 루틴이 멈추는것이 아닌 다른 고루틴이 os 스레드에서 실행될 수 있도록 한다.

func cond2() {
	c := sync.NewCond(&sync.Mutex{})

	queue := make([]interface{}, 0, 10)

	removeFromQueue := func(delay time.Duration) {
		time.Sleep(delay)
		c.L.Lock()
		queue = queue[1:]
		fmt.Println("Removed from queue")
		c.L.Unlock()
		c.Signal()
	}

	for i := 0; i < 10; i++ {
		c.L.Lock()
		for len(queue) == 2 {
			c.Wait()
		}
		fmt.Println("Adding to queue")
		queue = append(queue, struct{}{})
		go removeFromQueue(1 * time.Second)
		c.L.Unlock()
	}
}

 

10개의 항목이 추가되었으며, 마지막 두 개의 항목을 큐에서 꺼내기 전에 종료된다. wait의 조건에 따라 큐의 사이즈가 2가 되는 순간 1초의 대기시간이 걸리면서 remove 함수가 진행된다. 

여기서 signal이라는 새로운 메서드가 사용되는데 이는 cond의 wait에 대기 중인 고 루틴에게 신호를 보낸다. 이외에도 Broadcast라고 하는 메서드도 있다. 

 

런타임은 신호가 오기를 기다리는 고 루틴의 목록을 fifo(선입선출)의 형태를 유지한다. Signal 신호는 이중 가장 오래 기다린 고 루틴을 찾아서 알려주는 반면 Broadcast는 모든 고루 틴들에게 신호를 보낸다. 

 

2-4 Once

지난번 싱글턴 디자인 패턴에서도 등장하던 친구다. 이름에서 알 수 있듯이 함수를 정확하게 한 번만 호출하는 기능을 한다.

func Test_Once(t *testing.T) {
	var count int
	increment := func() {
		count++
	}
	decrement := func() {
		count--
	}

	var once sync.Once
	once.Do(increment)
	once.Do(decrement)

	fmt.Println(count)
}

이것의 결과는 0이 아닌 1이 나오게 된다. Do에 전달되는 함수의 호출 횟수가 아닌 Do 가 실행하는 횟수만을 계산하기 때문에 1의 결괏값을 받게 된다. 

 

2-5 Pool

pool 은 동시에 실행해도 안전한 책체 풀 패턴의 구현이다. 

일반적으로 데이터베이스 의 연결과 같은 비용이 많이 드는 것의 생성을 제한해 고정된 수의 개체만 생성하도록 하지만, 이러한 요청 혹은 연산이 얼마나 될지 알 수가 없다. 이런 경우 pool 은 매우 효과적으로 고 루틴 안에서 사용할 수 있다.

Get 메서드를 호출해 리턴 가능한 인스턴스가 pool 내에 있는지 확인하고, 그렇지 않으면 새 인스턴스를 만드는 new 멤버 변수를 호출한다. 이후 사용이 끝나면 반환을 위해 put을 호출한다.

func Test_Pool1(t *testing.T) {
	myPool := &sync.Pool{
		New: func() interface{} {
			fmt.Println("Creating new instance")
			return struct{}{}
		},
	}

	myPool.Get()
	instance := myPool.Get()
	myPool.Put(instance)
	myPool.Get()
}

Go 에는 가비지컬렉터가 존재하며 인스턴스 된 객체는 자동으로 정리된다. 그렇기에  객체의 캐시를 준비해야 하는 경우에 유용하게 사용할 수 있다. 호스트의 메모리르 보호하던, 사전로딩을 통해 신속한 결괏값을 표현할 때 사용된다.

 

한 가지 의문점이 들 수도 있다. sync 패키지에는 map 구조체 도 지원을 하고 있다. 고 루틴 간의 안전하게 데이터를 공유할 수 있게 하기 위해서 존재한다. 구조체 안에는 mutex, reader 등등 private으로 선언되어 있어 자체적으로 메서드 안에서 안전한 데이터 공유를 지원하게 해 준다. 그렇다면 sync.Map을 이용해서 저 캐시 기능을 구현가능하지 않겠는가 라는 의문점이 생길 수도 있다. 

 

위에서 언급했듯이 gc에 의해 이 pool 된 객체들은 제거된다. map을 이용해서 구현하게 된다면 계속 메모리에 들고 있어야 한다는 의미가 되고 이는 메모리 또한 관리해야 하는 치명적인 번거로움을 유발할 수도 있다.

 

pool 은 고비용 객체를 여러 번 재사용할 수 있도록 해주며, GC의 사이클이 실행되면 임시저장소에서 모든 객체가 수거된다. 이는 메모리 누수 방지에도 효과적이다. 따라서 Map과 pool 은 다른 동장방식과 다른 목적을 가지고 사용된다는 의미이다. 이를 혼동해서 사용하지 말자.

1. 동시성과 병렬성의 차이

- 동시성 은 "코드"의 속성, 병렬성은 "프로세스"의 속성이다.

  ㄱ. 우리가 작성한 코드들은 병렬로 실행되기를 바라면서 작성한다.

  ㄴ. 동시성 코드를 작성했다 할지라도, 실제로 병렬로 실행되는지 의 여부조차 모를 수가 있다.

  ㄷ. 병렬처리인지 아닌지는 컨택스트에 의해 결정된다. 

 

2. 대부분의 범용성 있는 언어들은 OS 스레드와 1:1 맵핑된 수준의 추상화 수준을 제공한다. 이는 다시 말해 동시성이 어려운 이유를 

야기하는 문제 가 실질적으로도 일어나는 레이어 계층이기도 하다. 

 

3. CSP 란 무엇인가 

- 상호작용 하는 순차적 프로세스들의 약자이다. 호어 가 제시한 논문의 약자로 "프로그래밍에서 두 가지 기본요소인 입력 및 출력이 간과되고 있으며, 특히 동시에 실행되는 코드의 경우에는 더욱 그렇다고 말한다." 논문에 제시된 코드를 보면 Go의 채널과  상당히 유사함을 알 수 있다. 

 

4. Go와 다른 대중적인 어어의 차이점 은 무엇인가?

 - OS 스레드 및 메모리 접근 동기화 수준에서의 언어 추상화 체인 이 기본언어의 틀인 반면, Go에서는 고 루틴 및 채널의 개념으로 이를 대체한다. 이에 고에서는 다음과 같은 통상적인 방법으로 코딩한다. 고루틴은 가볍기 때문에 고루틴 생성을 걸정할 필요는 없다. 

 

*자바로 작성된 스프링 프레임워크 와 고 의 에코 프레임워크는 어떻게 작동하는가?

각 요청마다 스프링 프레임워크는 아파치 톰캣 웹서버로부터 스레드풀에서 스레드를 할당받아 처리하고 스레드를 반환한다.

각 요청마다 고에서는 고 루틴을 생성해 처리하고 종료한다.

이것은 어떤 것이 더 빠르다고 할 수 없지만 GO에서는 리소스를 효율적으로 사용한다고 말할 수 있다.

 

5. Go의 동시성에 대한 철학

- 통신을 통해 메모리를 공유하고, 메모리 공유를 통해 통신하지 말라 이다. Go에서는 Sync 패키지를 이용해 전통적인 잠금 메커니즘을 사용할 수도 있고 혹은 채널을 이용해서 해결할 수 도 있다. 이에 언제 어떤 방식을 써야 하는지에 대해 트리를 이용해 제공하고 있다.

- 데이터 소유권을 이전하려는가? => 채널을 사용하면 동시성 코드를 다른 동시성 코드와 함께 구성할 수 있다는 점이다.

- 구조체의 내부 상태를 보호하고자 하는가? => 메모리 접근 동기화 기본요소를 사용하수 있는 최적의 선택지이다.

- 여러 부분 논리를 조정해야 하는가? => 채널을 사용한다면 Select 문을 활용해 긴급한 복잡성을 훨씬 쉽게 제어할 수 있다.

- 성능상의 임계영역 인가? => 해당영역 이 프로그램의 나머지 부분보다 현저하게 느린 주요 병먹 지점이라면, 메모리 접근 동기화 기본요소를 사용하자 이다.

 

고 루틴을 사용해 문제 공간을 모델링하고 작업 중 동시에 수행되는 부분을 표현하기 위해 고루틴을 사용하자.

단순화를 목표로 하고, 가능하면 채널을 사용하며 고 루틴을 무한정 쓸 수 있는 자원처럼 다루어라.!

 

Go 언어 의 꽃이라고 할 수 있는 동시성에 대해 공부해보고자 한다. 

우선 1장에서는 용어에 대한 설명 과 정의를 하는데 보다 명확하게 이해하고 넘어가고자 작성한다.

 

1. 동시성 은 왜 컴퓨터 과학에서 중요한가?

-GUI에 기반한 프로그램은 사용자의 버튼 클릭 와 해당 버튼의 동작으로 이루어진다. 이러한 프로그램 파이프라인은 순차적으로 실행될 수밖에 없는 사용자와의 상호작용에 의해 성능이 제한된다. 다시 말해 몇 개의 코어를 가지던, 상관없이 사용자가 얼마나 빠르게 클릭하느냐 에 따라 이 프로그램의 성능이 결정된다는 의미이다. 

- 스피곳 알고리즘 (원주율의 수수점 의 각자릿수에 대해 병렬로 처리하는 것)을 과잉병렬이라고 한다. 이를 해결하기 위해 프로그램에 서 더 많은 코어를 사용할 수 있게 만든다면 보다 성능향상 이 가능하다 반면 이에 각 코어 간의 결과를 어떻게 결합하고 저장하는가에 대한 새로운 문제점 이 발생한다. 이에 암달법칙 (병렬로 작업할 수 있는 부분이 많을수록 효율적으로 문제를 해결하는 것) 이 병렬처리가 시스템 성능 문제를 해결하는데 도움을 주는지에 대해 파악할 수 있다.

이런 병렬처리를 쉽게 하기 위해서는 프로그램, 애플리케이션을 수평적으로 작성할 것을 권장한다.

- 클라우딩 컴퓨팅에 의해 배포 및 수평확장에 새로운 패러다임이 등장하게 된다. 비교적 저렴한 비용으로 대교모 문제를 해결할 수 있는 컴퓨팅 성능에 접근하게 되었다. 그러나 이러한 자원들을 프로비저닝 하고, 인스턴스 간의 통신, 결과집계 저장 등 의 문제를 동시적으로 해결하는데 상당한 어려움이 있었으며 상황을 악화시키는 결과도 초래하곤 했다.

- 웹스케일(클라우드 회사에서 서버, 스토리지, 네트워크, 보안 등 구성요소를 최적화해서 운용하는 것) 이 등장하며, 과잉병렬을 가능하게 해 주었다

 

무어의 법칙이 깨짐 에 따라 , 하드웨의 기술발전이 이전만큼 유지되지 않고 있고 소프트웨어의 성능향상에 의존하기 어려워지고 있는 지금 동시성(멀티프로세스) 통한 성능향상을 추구하는 경향이 점점 더 강해지고 있고, 무어의 법칙의 깨짐에 대한 대안적인 접근 방법이 되어, 현 컴퓨터 과학에서 매우 중요한 개념으로 인식되고 있다.

 

2. 동시성 이 어려운 이유

- 레이스컨디션 : 하나의 동시작업이 어떤 변수를 읽으려고 시도하는 동안 또 다른 동시작업이 특정할 수 없는 시점에 동일변수에 값을 쓰려고 하는 데이터 레이스, 동시성 버그 중 가장 은밀한 유형 중 하나.

- 원자성 : 동작하는 콘텍스트 내에서 나누어지거나 중단되지 않는 것을 의미한다. 다시 말해 동시 실행되는 컨택스트 내에서 원자적이라 하면 안전하다는 것을 의미한다.

- 메모리접근 동기화: 각 프로그램 언어에서는 임계영역(공유리소스에 독점적 접근을 해야 하는 영역)의 동기화하는 다양한 방법을 제공한다. 

-데드락 : 두 개의 작업이 서로의 작업이 끝나기만을 기다리는 상태

-라이브락:  동시에 연산을 수행하고 있지만, 이 연산들이 실제 프로그램의 상태를 진행하는데 아무런 영향을 주지 못하는 상태

-기아상태: 실행하는데 필요한 모든 리소스를 얻을 수 없는 상태

Go에서는 동시성의 문제를 돕기 위해 다양한 기본요소들은, 동시성 알고리즘을 보다 안전하고 명확하게  표현할 수 있다. 

더보기
func race() {

	var memoryAccess sync.Mutex

	var data int
	go func() {
		memoryAccess.Lock()
		data++
		memoryAccess.Unlock()
		fmt.Printf("in the go routine the value is %v.\n", data)
	}()
	memoryAccess.Lock()
	if data == 0 {
		fmt.Printf("the value is %v.\n", data)
	} else {
		fmt.Printf("the value is %v.\n", data)
	}
	memoryAccess.Unlock()
}

type value struct {
	mu    sync.Mutex
	value int
}

func deadLock() {
	var wg sync.WaitGroup
	printSum := func(v1, v2 *value) {
		defer wg.Done()
		v1.mu.Lock()
		defer v1.mu.Unlock()
		time.Sleep(2 * time.Second)
		v2.mu.Lock()
		defer v2.mu.Unlock()
		fmt.Printf("sum=%v\n", v1.value+v2.value)
	}
	var a, b value
	wg.Add(2)
	go printSum(&a, &b)
	go printSum(&b, &a)
	wg.Wait()
}

func liveLock() {
	cadence := sync.NewCond(&sync.Mutex{})
	go func() {
		for range time.Tick(1 * time.Millisecond) {
			cadence.Broadcast()
		}
	}()

	takeStep := func() {
		cadence.L.Lock()
		cadence.Wait()
		cadence.L.Unlock()
	}

	tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
		fmt.Fprintf(out, " %v", dirName)
		atomic.AddInt32(dir, 1)
		takeStep()
		if atomic.LoadInt32(dir) == 1 {
			fmt.Fprintf(out, ". Success!")
			return true
		}
		takeStep()
		atomic.AddInt32(dir, -1)
		return false
	}

	var left, right int32
	tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out) }
	tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out) }

	walk := func(walking *sync.WaitGroup, name string) {
		var out bytes.Buffer
		defer func() { fmt.Println(out.String()) }()
		defer walking.Done()
		fmt.Fprintf(&out, "%v is trying to scoot:", name)
		for i := 0; i < 5; i++ {
			if tryLeft(&out) || tryRight(&out) {
				return
			}
		}
		fmt.Fprintf(&out, "\n%v tosses her hands up in exasperation!", name)
	}

	var peopleInHallway sync.WaitGroup
	peopleInHallway.Add(2)

	go walk(&peopleInHallway, "Alice")
	go walk(&peopleInHallway, "Barbara")

	peopleInHallway.Wait()
}

func starvation() {
	var wg sync.WaitGroup
	var sharedLock sync.Mutex
	const runtime = 1 * time.Second

	greedyWorkder := func() {
		defer wg.Done()
		var count int
		for begin := time.Now(); time.Since(begin) <= runtime; {
			sharedLock.Lock()
			time.Sleep(3 * time.Nanosecond)
			sharedLock.Unlock()
			count++
		}
		fmt.Printf("Greedy worker was able to execute %v work loops.\n", count)
	}

	polliteWorker := func() {
		defer wg.Done()

		var count int
		for begin := time.Now(); time.Since(begin) <= runtime; {
			sharedLock.Lock()
			time.Sleep(1 * time.Nanosecond)
			sharedLock.Unlock()
			sharedLock.Lock()
			time.Sleep(1 * time.Nanosecond)
			sharedLock.Unlock()
			sharedLock.Lock()
			time.Sleep(1 * time.Nanosecond)
			sharedLock.Unlock()
			count++
		}
		fmt.Printf("Polite worker was able to execute %v work loops.\n", count)
	}

	wg.Add(2)

	go greedyWorkder()
	go polliteWorker()

	wg.Wait()
}




+ Recent posts