당신 앞에 여러갈래 길이 펼쳐지는데, 어떤 길을 선택할지 모를때 무턱대고 아무 길이나 택하지 마라.
차분히 앚아라. 그리고 기다려라. 기다리고 또 기다려라.
꼼짝하지 마라. 입을 다물고 가슴의 소리를 들어라.
그리고 가슴이 당신에게 말할때, 그때 일어나 가슴이 이끄는 길로 가라.
-. 수잔나 다마로
2009년 12월 29일 화요일
2009년 12월 14일 월요일
sse 참고 사이트
http://zupet.tistory.com/317
http://softpixel.com/~cwright/programming/simd/sse2.php
http://blog.paran.com/alotta/13998621
http://www.codeproject.com/KB/recipes/BubbleSortWithSSE2.aspx
http://msdn.microsoft.com/en-us/library/0aws1s9k.aspx
http://inha.inwebcard.kr/sub.php?tname=1243339800
http://softpixel.com/~cwright/programming/simd/sse2.php
http://blog.paran.com/alotta/13998621
http://www.codeproject.com/KB/recipes/BubbleSortWithSSE2.aspx
http://msdn.microsoft.com/en-us/library/0aws1s9k.aspx
http://inha.inwebcard.kr/sub.php?tname=1243339800
2009년 12월 13일 일요일
가슴 뛰는 삶
머리가 하얗게 센 노인이 눈을 감은 채, 공원의 의자에 앚아 있었다. 하룻밤이 지나서도 그는 그곳에 그렇게 앉아 있었다. 공원 관리인이 노인에게 물었다.
'댁은 뉘시오?'
'어디서 오셨수?'
의자에 앉아 있던 노인은 천천히 고개를 돌리더니 다음과 같이 대답했다.
'만일 내가 누구인지, 어디로부터 왔는지 안다면, 이 결정적인 질문의 답을 찾기 위해 여기 이렇게 앚아 있지 않을 것이오. 내가 알고 있는 것은 오직 '아서 쇼펜하우어'라는 이름뿐이외다.'
당신은 누구인가? 당신은 왜 지금 이 모습인가? 왜 늘 꿈꾸어오던 그 모습이 아니고 지금 이 모습인가? 무엇이 지금의 이 모습으로 만들었는가? 이것은 불가피한, 어쩔 수 없느 ㄴ선택의 결과인가?, 아니면 우연한 귀결인가? 그렇다면 지금 이대로가 좋은가? 그냥 이대로 머물 작정인가? 어디로 가려는가? 원래 어디로 가는 중이었고 어디로 갔어야 했나? 어디로 가면 당신에게 더 잘 어울리는 내일이 시작되겠는가?
<가슴 뛰는 삶>은 이렇게 시작한다.
과연 '당신이 원하는 삶이 이런 것?'이라는 질문에 답을 내놓을 수 있는가?
'댁은 뉘시오?'
'어디서 오셨수?'
의자에 앉아 있던 노인은 천천히 고개를 돌리더니 다음과 같이 대답했다.
'만일 내가 누구인지, 어디로부터 왔는지 안다면, 이 결정적인 질문의 답을 찾기 위해 여기 이렇게 앚아 있지 않을 것이오. 내가 알고 있는 것은 오직 '아서 쇼펜하우어'라는 이름뿐이외다.'
당신은 누구인가? 당신은 왜 지금 이 모습인가? 왜 늘 꿈꾸어오던 그 모습이 아니고 지금 이 모습인가? 무엇이 지금의 이 모습으로 만들었는가? 이것은 불가피한, 어쩔 수 없느 ㄴ선택의 결과인가?, 아니면 우연한 귀결인가? 그렇다면 지금 이대로가 좋은가? 그냥 이대로 머물 작정인가? 어디로 가려는가? 원래 어디로 가는 중이었고 어디로 갔어야 했나? 어디로 가면 당신에게 더 잘 어울리는 내일이 시작되겠는가?
<가슴 뛰는 삶>은 이렇게 시작한다.
과연 '당신이 원하는 삶이 이런 것?'이라는 질문에 답을 내놓을 수 있는가?
나는 배웠다
나는 배웠다
오마르 워싱턴
나는 배웠다.
다른 사람이 나를 사랑하게 만들 수는 없다는 것을.
내가 할 수 있는 일은 사랑 받을 만한 사람이 되는 것뿐임을.
사랑을 받는 일은 그 사람의 선택에 달렸으므로.
나는 배웠다. 아무리 마음 깊이 배려해도
어떤 사람은 꿈쩍도 하지 않는다는 것을.
신뢰를 쌓는 데는 여러 해가 걸려도
무너지는 것은 한순간이라는 것을.
인생에선 무엇을 손에 쥐고 있는가보다
누구와 함께 있느냐가 더 중요하다는 것을 나는 배웠다.
우리의 매력은 15분을 넘지 못하고
그 다음은 서로 배워가는 것이 더 중요하다는 것을.
나는 배웠다. 다른 사람의 최대치에 나를 비교하기보다
내 자신의 최대치에 나를 비교해야 한다는 것을.
또 무슨 일이 일어나는가보다
그 일에 어떻게 대처하는가가 중요하다는 것을.
무엇을 아무리 얇게 베어내도 거기엔 늘 양면이 있다는 것을.
어느 순간이 우리의 마지막이 될지 모르기 때문에
사랑하는 사람에게 언제나 사랑의 말을 넘겨놓고 떠나야 함을.
더 못 가겠다고 포기한 뒤에도 훨씬 멀리 갈 수 있다는 것을.
결과에 연연하지 않고 마땅히 해야 할 일을 하는 사람이
진정한 영웅이라는 것을 나는 배웠다.
깊이 사랑하면서도 그것을 드러낼 줄 모르는 이가 있다는 것을.
내게도 분노할 권리는 있으나 남을 잔인하게 대할 권리는 없다는 것을.
멀리 떨어져 있어도 우정이 계속되듯 사랑 또한 그렇다느 것을.
가끔은 절친한 친구도 나를 아프게 한다는 것을.
그래도 그들을 용서해야 한다는 것을 나는 배웠다.
남에게 용서를 받는 것만으로는 충분치 않고
자신을 용서하는 법을 배워야 한다는 것을.
아무리 내 마음이 아프다 해도 이 세상은
내 슬픔 때문에 운행을 중단하지 않다는 것을.
두 사람이 다툰다고 서로 사랑하지 않는 게 아니며
다투지 않는다고 해서 사랑하는 게 아니라는 것도.
또 나는 배웠다. 때론 남보다 내가 먼저 움직여야 한다는 것을.
두 사람이 한 사물을 보다라도 관점은 다르다는 것을.
결과에 상관없이 자신에게 정직한 사람이 결국 앞선다는 것을.
친구가 도와달라고 소리칠 때 없던 힘이 솟는 것처럼
자신의 삶이 순식간에 바뀔 수도 있다는 것을.
글 쓰는 일이 대화하는 것처럼 아픔을 덜어준다는 것을.
가장 아끼는 사람이 너무 빨리 떠나버릴 수도 있다는 것을.
나는 배웠다. 남의 마음을 아프게 하지 않는 것과
내 주장을 분명히 하는 것을 구분하기가 얼마나 어려운가를.
그리고 나는 배웠다.
사랑하는 것과 사랑받는 것의 지정한 의미를.
시 읽는 CEO 중에서...
오마르 워싱턴
나는 배웠다.
다른 사람이 나를 사랑하게 만들 수는 없다는 것을.
내가 할 수 있는 일은 사랑 받을 만한 사람이 되는 것뿐임을.
사랑을 받는 일은 그 사람의 선택에 달렸으므로.
나는 배웠다. 아무리 마음 깊이 배려해도
어떤 사람은 꿈쩍도 하지 않는다는 것을.
신뢰를 쌓는 데는 여러 해가 걸려도
무너지는 것은 한순간이라는 것을.
인생에선 무엇을 손에 쥐고 있는가보다
누구와 함께 있느냐가 더 중요하다는 것을 나는 배웠다.
우리의 매력은 15분을 넘지 못하고
그 다음은 서로 배워가는 것이 더 중요하다는 것을.
나는 배웠다. 다른 사람의 최대치에 나를 비교하기보다
내 자신의 최대치에 나를 비교해야 한다는 것을.
또 무슨 일이 일어나는가보다
그 일에 어떻게 대처하는가가 중요하다는 것을.
무엇을 아무리 얇게 베어내도 거기엔 늘 양면이 있다는 것을.
어느 순간이 우리의 마지막이 될지 모르기 때문에
사랑하는 사람에게 언제나 사랑의 말을 넘겨놓고 떠나야 함을.
더 못 가겠다고 포기한 뒤에도 훨씬 멀리 갈 수 있다는 것을.
결과에 연연하지 않고 마땅히 해야 할 일을 하는 사람이
진정한 영웅이라는 것을 나는 배웠다.
깊이 사랑하면서도 그것을 드러낼 줄 모르는 이가 있다는 것을.
내게도 분노할 권리는 있으나 남을 잔인하게 대할 권리는 없다는 것을.
멀리 떨어져 있어도 우정이 계속되듯 사랑 또한 그렇다느 것을.
가끔은 절친한 친구도 나를 아프게 한다는 것을.
그래도 그들을 용서해야 한다는 것을 나는 배웠다.
남에게 용서를 받는 것만으로는 충분치 않고
자신을 용서하는 법을 배워야 한다는 것을.
아무리 내 마음이 아프다 해도 이 세상은
내 슬픔 때문에 운행을 중단하지 않다는 것을.
두 사람이 다툰다고 서로 사랑하지 않는 게 아니며
다투지 않는다고 해서 사랑하는 게 아니라는 것도.
또 나는 배웠다. 때론 남보다 내가 먼저 움직여야 한다는 것을.
두 사람이 한 사물을 보다라도 관점은 다르다는 것을.
결과에 상관없이 자신에게 정직한 사람이 결국 앞선다는 것을.
친구가 도와달라고 소리칠 때 없던 힘이 솟는 것처럼
자신의 삶이 순식간에 바뀔 수도 있다는 것을.
글 쓰는 일이 대화하는 것처럼 아픔을 덜어준다는 것을.
가장 아끼는 사람이 너무 빨리 떠나버릴 수도 있다는 것을.
나는 배웠다. 남의 마음을 아프게 하지 않는 것과
내 주장을 분명히 하는 것을 구분하기가 얼마나 어려운가를.
그리고 나는 배웠다.
사랑하는 것과 사랑받는 것의 지정한 의미를.
시 읽는 CEO 중에서...
가난한 사랑 노래
가난한 사랑 노래
신경림
가난하다고 해서 외로움을 모르겠는가
너와 헤어져 돌아오는
눈 쌓인 골목길에 새파랗게 달빛이 쏟아지는데,
가난하다고 해서 두려움이 없겠는가
두 점을 치는 소리
방법대원의 호각소리 메밀묵 사려 소리에
눈을 뜨면 멀리 육중한 기계 굴러가는 소리.
가난하다고 해서 그리움을 버렸겠는가.
어머님 보고 싶소 수없이 뇌어 보지만
집 뒤 감나무 까치밥으로 하나 남았을
새빨간 감 바람소리도 그려 보지만,
가난하다고 해서 사랑을 모르겠는가
내 볼에 와 닿던 네 입술의 뜨거움
사랑한다고 사랑한다고 속삭이던 네 숨결
돌아서는 내 등 뒤에서 터지던 네 울음.
가난하다고 왜 모르겠는가
가난하기 때문에 이것들을
이 모든 것들을 버려야 한다는 것을.
「 언제나 읽어도 콧등이 찡해지는 시 중에 신경림의 <가난한
사랑노래>가 있다. 언젠가 신경림 시인으로부터 이 시를 쓰게
된 사연을 들었다. 그가 길음동 산동네에 살 때였다고 한다.
집 근처에 자주 들르던 술집이 있었고, 거기서 한 가난한 젋은이
를 알게 됐다. 그러나 한편으로는 많이 배우지 못하고 가난한
처지를 못내 부끄러워하는 순박한 젊은이 였다.
어느 날 그 청년이 고민을 털어놨다. 바로 이 단골술집 딸과 사랑
하는 사이인데, 자신이 너무 가난해 결혼 애기를 꺼내기가 힘들
다는 것이었다. 하긴 딸을 가진 부모로서 빈곤한 노동자를 사위
로 맞아들이고 싶지 않았을 것이다. 실제로 그 청년은 그집 딸과
헤어졌다가 다시 만나기를 여러 번 해왔다고 말했다.
그 애기를 든은 신경림 시인은 청년에게 모든 어려움을 극복하고
둘이 결혼을 하면 주례도 해주고 결혼 축시도 써주겠노라고 약속
을 했다. 그 희망에 힘을 얻었는지 둘은 머지않아 결혼식을 올렸
다. 당시 결혼식장에서 시인이 그들을 위해서 읽어 준 축시가
바로 <너희 사랑>이다.
너희 사랑
낡은 교회 담벼락에 쓰여진
자잘한 낙서에서 너희 사랑은 싹텄다
흙바람 맵찬 골목과 불기 없는
자취방을 오가며 너희 사랑은 자랐다
가난이 싫다고 이렇게 살고 싶지는 않다고
반병의 소주와 한 마리 노가리를 놓고
망설이고 헤어지기를 여러 번이었지만
뉘우치고 다짐하기 또 여러 밤이었지만
망설임과 헤매임 속에서 너희 사랑은
굳어졌다
새삶 찾아나서는
다짐 속에서 너희 사라은 깊어 졌다
돌팔매와 최루탄에 찬 마룻바닥과
푸른옷에 비틀대기도 했으나
소주집과 생맥주집을 오가며
다시 너희 사랑은 다져졌다
그리하여 이제 너희 사랑은
낡은 교회 담벼락에 쓰여진
낚서처럼 눈에 익은 너희 사랑은
단비가 되어 산동네를 적시는구나
혼풍이 되어 산동네를 누비는 구나
골목길 오가며 싹튼 너희 사랑은
새삶 찾아나서는 다짐 속에서
깊어지고 다져진 너희 사랑은
이렇게 애틋한 사랑의 결실이었음에도 그들의 결혼식은 어느 비좁고 허름한 지하실에서 이뤄졌다. 청년이 노동 운동으로 지명수배를 받아 쫒기는 신세였기 때문이었다.
그러나 이 은밀한 결혼식에는 순박한 감동이 있었다. 축하객은 다 합쳐봐야 열댓 명 정도였지만 모두 마음 깊은 곳에서 우러나오는 축하를 보냈다.
그날 결혼식이 끝나자마자 곧장 집으로 돌아온 시인은 두 사람이 겪은 마음고생과 인생의 쓰라림을 달래는 마음으로 시 한 편을 더 쓰게 되었다 그때 탄생한 시가 바로 <가난한 사랑 노래>다.
시인의 얘기처럼 가난이란 인생에서 큰 멍에지만, 가난하다고해서 사랑을 왜면하거나 꿈을 접을 수는 없다는 긍정성 또한 영원한 진실이다.
신경림 시인은 이 시에서 '가난하기에 오히려 더욱 더 치열하게 살아야만 한다'고 말하고 싶었다고 한다. 쉽게 좌절하지 않고 노력하는 젊은이들에게는 반드시 좋은 결과가 있다는 것을 잘 알았기 때문이다.
다행히 그 젊은이들은 지금 중년에 접어들어 넉적하지 않지만 행복하게 살고 있다고 한다. 신경림 시인은 21세 때 <갈대>라는 시로 등단 한 이후, 자청해서 남을 위한 헌사를 붙인 시를 쓴 적은 없다고 했다. 이 두 편의 시는 그가 '이웃의 한 젋은이' 와 '누이'에게 주는 각별한 애정의 증표다
그렇다. 때로는 결핍이 충족을 완성한다.」
시 읽는 CEO 중에서...
신경림
가난하다고 해서 외로움을 모르겠는가
너와 헤어져 돌아오는
눈 쌓인 골목길에 새파랗게 달빛이 쏟아지는데,
가난하다고 해서 두려움이 없겠는가
두 점을 치는 소리
방법대원의 호각소리 메밀묵 사려 소리에
눈을 뜨면 멀리 육중한 기계 굴러가는 소리.
가난하다고 해서 그리움을 버렸겠는가.
어머님 보고 싶소 수없이 뇌어 보지만
집 뒤 감나무 까치밥으로 하나 남았을
새빨간 감 바람소리도 그려 보지만,
가난하다고 해서 사랑을 모르겠는가
내 볼에 와 닿던 네 입술의 뜨거움
사랑한다고 사랑한다고 속삭이던 네 숨결
돌아서는 내 등 뒤에서 터지던 네 울음.
가난하다고 왜 모르겠는가
가난하기 때문에 이것들을
이 모든 것들을 버려야 한다는 것을.
「 언제나 읽어도 콧등이 찡해지는 시 중에 신경림의 <가난한
사랑노래>가 있다. 언젠가 신경림 시인으로부터 이 시를 쓰게
된 사연을 들었다. 그가 길음동 산동네에 살 때였다고 한다.
집 근처에 자주 들르던 술집이 있었고, 거기서 한 가난한 젋은이
를 알게 됐다. 그러나 한편으로는 많이 배우지 못하고 가난한
처지를 못내 부끄러워하는 순박한 젊은이 였다.
어느 날 그 청년이 고민을 털어놨다. 바로 이 단골술집 딸과 사랑
하는 사이인데, 자신이 너무 가난해 결혼 애기를 꺼내기가 힘들
다는 것이었다. 하긴 딸을 가진 부모로서 빈곤한 노동자를 사위
로 맞아들이고 싶지 않았을 것이다. 실제로 그 청년은 그집 딸과
헤어졌다가 다시 만나기를 여러 번 해왔다고 말했다.
그 애기를 든은 신경림 시인은 청년에게 모든 어려움을 극복하고
둘이 결혼을 하면 주례도 해주고 결혼 축시도 써주겠노라고 약속
을 했다. 그 희망에 힘을 얻었는지 둘은 머지않아 결혼식을 올렸
다. 당시 결혼식장에서 시인이 그들을 위해서 읽어 준 축시가
바로 <너희 사랑>이다.
너희 사랑
낡은 교회 담벼락에 쓰여진
자잘한 낙서에서 너희 사랑은 싹텄다
흙바람 맵찬 골목과 불기 없는
자취방을 오가며 너희 사랑은 자랐다
가난이 싫다고 이렇게 살고 싶지는 않다고
반병의 소주와 한 마리 노가리를 놓고
망설이고 헤어지기를 여러 번이었지만
뉘우치고 다짐하기 또 여러 밤이었지만
망설임과 헤매임 속에서 너희 사랑은
굳어졌다
새삶 찾아나서는
다짐 속에서 너희 사라은 깊어 졌다
돌팔매와 최루탄에 찬 마룻바닥과
푸른옷에 비틀대기도 했으나
소주집과 생맥주집을 오가며
다시 너희 사랑은 다져졌다
그리하여 이제 너희 사랑은
낡은 교회 담벼락에 쓰여진
낚서처럼 눈에 익은 너희 사랑은
단비가 되어 산동네를 적시는구나
혼풍이 되어 산동네를 누비는 구나
골목길 오가며 싹튼 너희 사랑은
새삶 찾아나서는 다짐 속에서
깊어지고 다져진 너희 사랑은
이렇게 애틋한 사랑의 결실이었음에도 그들의 결혼식은 어느 비좁고 허름한 지하실에서 이뤄졌다. 청년이 노동 운동으로 지명수배를 받아 쫒기는 신세였기 때문이었다.
그러나 이 은밀한 결혼식에는 순박한 감동이 있었다. 축하객은 다 합쳐봐야 열댓 명 정도였지만 모두 마음 깊은 곳에서 우러나오는 축하를 보냈다.
그날 결혼식이 끝나자마자 곧장 집으로 돌아온 시인은 두 사람이 겪은 마음고생과 인생의 쓰라림을 달래는 마음으로 시 한 편을 더 쓰게 되었다 그때 탄생한 시가 바로 <가난한 사랑 노래>다.
시인의 얘기처럼 가난이란 인생에서 큰 멍에지만, 가난하다고해서 사랑을 왜면하거나 꿈을 접을 수는 없다는 긍정성 또한 영원한 진실이다.
신경림 시인은 이 시에서 '가난하기에 오히려 더욱 더 치열하게 살아야만 한다'고 말하고 싶었다고 한다. 쉽게 좌절하지 않고 노력하는 젊은이들에게는 반드시 좋은 결과가 있다는 것을 잘 알았기 때문이다.
다행히 그 젊은이들은 지금 중년에 접어들어 넉적하지 않지만 행복하게 살고 있다고 한다. 신경림 시인은 21세 때 <갈대>라는 시로 등단 한 이후, 자청해서 남을 위한 헌사를 붙인 시를 쓴 적은 없다고 했다. 이 두 편의 시는 그가 '이웃의 한 젋은이' 와 '누이'에게 주는 각별한 애정의 증표다
그렇다. 때로는 결핍이 충족을 완성한다.」
시 읽는 CEO 중에서...
굽이 돌아가는 길
굽이 돌아가는 길
박 노 해
올돋게 뻗은 나무들보다
휘어 자란 소나무가 더 멋있습니다.
똑바로 흘러가는 물줄기보다는
휘청 굽어진 강줄기가 더 정답습니다.
일직선으로 뚫린 빠른 길 보다는
산 따라 물 따라 가는 길이 더 아릅답습니다.
곧은 길 끊어져 길이 없다고
주저앉지 마십시요
돌아서지 마십시요
삶은 가는 것입니다
그래도 가는 것입니다
우리가 살아 있다는 건
아직도 가야 할 길이 있다는 것
곧은 길만이 길이 아닙니다
빛나는 길만이 길이 아닙니다.
굽이 돌아가는 길이 멀고 쓰라릴지라도
그래서 더 깊어지고 환해져 오는 길
서둘지 말고 가는 것입니다
서로가 길이 되어가는 것입니다
생을 두고 끝까지 가는 것입니다
시 읽는 CEO 중에서...
박 노 해
올돋게 뻗은 나무들보다
휘어 자란 소나무가 더 멋있습니다.
똑바로 흘러가는 물줄기보다는
휘청 굽어진 강줄기가 더 정답습니다.
일직선으로 뚫린 빠른 길 보다는
산 따라 물 따라 가는 길이 더 아릅답습니다.
곧은 길 끊어져 길이 없다고
주저앉지 마십시요
돌아서지 마십시요
삶은 가는 것입니다
그래도 가는 것입니다
우리가 살아 있다는 건
아직도 가야 할 길이 있다는 것
곧은 길만이 길이 아닙니다
빛나는 길만이 길이 아닙니다.
굽이 돌아가는 길이 멀고 쓰라릴지라도
그래서 더 깊어지고 환해져 오는 길
서둘지 말고 가는 것입니다
서로가 길이 되어가는 것입니다
생을 두고 끝까지 가는 것입니다
시 읽는 CEO 중에서...
2009년 9월 18일 금요일
SubclassWindow 주의사항
MFC CWnd 클래스의 SubclassWindow()를 사용함에 있어 주의해야 하는 제약 사항이 있다.
이미 CWnd 오브젝트에 맵핑되어 있는 hWnd를 파라미터로 하여 CWnd::SubclassWindow()를 호출하게 되면 충돌이 발생한다. 이런 경우는 서브클래싱을 통해 추가적인 기능을 제공하는 범용 클래스를 소스 레벨에서 통합시킬 때 발생할 수 있다.
정확히 어떤 경우인지 예를 들어 살펴보자.
class CExWnd : public CWnd
{
...
};
class CSomeTool : public CObject
{
CExWnd m_exWnd;
void ExtendWnd(CWnd* pWnd);
};
void CSomeTool::ExtendWnd(CWnd* pWnd)
{
m_exWnd.SubclassWindow(pWnd->GetSafeHwnd());
}
CSomeTool::ExtendWnd(CWnd* pWnd) 함수는 파라미터로 받은 pWnd의 윈도 핸들을 CExWnd로 서브클래싱을 시도하고 있다. 이때 pWnd가 permanent 오브젝트인 경우 CWnd::SubclassWindow() 함수에서 충돌이 발생한다. Permanent 오브젝트의 여부는 CWnd::FromHandlePermanent(HWND hWnd)를 통해 검사할 수 있다.
충돌의 원인은 MFC가 CWnd 오브젝트와 윈도 핸들을 맵핑시키는 메카니즘에서 기인한다.
MFC는 내부에 윈도 맵이라는 해쉬 테이블을 통해 윈도 핸들과 CWnd 오브젝트를 연관시켜 관리한다. 해쉬의 특성상 하나의 윈도 핸들에는 오직 하나의 CWnd 오브젝트만이 맵핑될 수 있다.
그런데 CWnd 오브젝트에 이미 맵핑되어 있는 윈도 핸들을 또 다른 CWnd 오브젝트인 CExWnd에 서브클래싱을 시도하게 되면 하나의 윈도 핸들에 두 개의 CWnd 오브젝트가 존재하는 상황이 발생하여 MFC의 내부 윈도 맵에 충돌이 발생하는 것이다.
단순히 이것만이 문제라면 SubclassWindow()를 호출하기 전에 CWnd::FromHandlePermanent()를 통해 얻은 permanent CWnd 오브젝트의 Detach() 함수를 먼저 호출하여 MFC 윈도 맵의 충돌을 피할 수 있다.
그러나 이것보다 더 심각한 문제가 있다.
MFC는 모든 CWnd 클래스에 대해 AfxWndProc()이라는 윈도 프로시저를 사용하고 있다. 즉, 서브클래싱 하고자 하는 permanent 윈도 오브젝트도 AfxWndProc()을 사용하고 있고 서브클래싱된 CExWnd 클래스도 AfxWndProc()을 사용하고 있다. CWnd::SubclassWindow()는 파라미터로 전달된 윈도의 윈도 프로시저를 상위 윈도 프로시저(oldWndProc)로 저장하고 자신의 윈도 프로시저(AfxWndProc)를 설정한다. AfxWndProc()에서는 CallWindowProc()을 호출하여 서브클래싱 되기 전의 상위 윈도 프로시저(oldWndProc)를 호출한다. 그런데 서브클래싱된 CExWnd는 상위 윈도 프로시저(oldWndProc)에 AfxWndProc을 담고 있으므로 AfxWndProc이 무한 반복 호출되는 상황이 발생한다.
이것은 MFC의 내부 구조상 어쩔 수 없는 제약사항이다.
해결책은 두 가지가 있을 수 있다.
첫째는 범용 클래스를 소스 레벨에서 통합하지 말고 별도의 DLL로 빌드하여 통합할 것.
DLL로 빌드하면 CExWnd는 DLL 컨텍스트에서 관리되는 MFC 윈도 맵과 AfxWndProc을 사용하게 된다. 파라미터로 넘겨지는 윈도 핸들이 permanent CWnd 오브젝트의 핸들이라 할지라도 Exe 컨텍스트에서 사용되는 CWnd 오브젝트라면 충돌이 일어나지 않는다.
둘째는 직접 WNDPROC 타입의 콜백 함수(fnExWndProc)를 만들어 SetWindowLongPtr(hWnd, GWLP_WNDPROC, fnExWndProc)로 직접 서브클래싱할 것.
당연하다. MFC를 사용하지 않는 서브클래싱이면 아무 문제도 없다.
이미 CWnd 오브젝트에 맵핑되어 있는 hWnd를 파라미터로 하여 CWnd::SubclassWindow()를 호출하게 되면 충돌이 발생한다. 이런 경우는 서브클래싱을 통해 추가적인 기능을 제공하는 범용 클래스를 소스 레벨에서 통합시킬 때 발생할 수 있다.
정확히 어떤 경우인지 예를 들어 살펴보자.
class CExWnd : public CWnd
{
...
};
class CSomeTool : public CObject
{
CExWnd m_exWnd;
void ExtendWnd(CWnd* pWnd);
};
void CSomeTool::ExtendWnd(CWnd* pWnd)
{
m_exWnd.SubclassWindow(pWnd->GetSafeHwnd());
}
CSomeTool::ExtendWnd(CWnd* pWnd) 함수는 파라미터로 받은 pWnd의 윈도 핸들을 CExWnd로 서브클래싱을 시도하고 있다. 이때 pWnd가 permanent 오브젝트인 경우 CWnd::SubclassWindow() 함수에서 충돌이 발생한다. Permanent 오브젝트의 여부는 CWnd::FromHandlePermanent(HWND hWnd)를 통해 검사할 수 있다.
충돌의 원인은 MFC가 CWnd 오브젝트와 윈도 핸들을 맵핑시키는 메카니즘에서 기인한다.
MFC는 내부에 윈도 맵이라는 해쉬 테이블을 통해 윈도 핸들과 CWnd 오브젝트를 연관시켜 관리한다. 해쉬의 특성상 하나의 윈도 핸들에는 오직 하나의 CWnd 오브젝트만이 맵핑될 수 있다.
그런데 CWnd 오브젝트에 이미 맵핑되어 있는 윈도 핸들을 또 다른 CWnd 오브젝트인 CExWnd에 서브클래싱을 시도하게 되면 하나의 윈도 핸들에 두 개의 CWnd 오브젝트가 존재하는 상황이 발생하여 MFC의 내부 윈도 맵에 충돌이 발생하는 것이다.
단순히 이것만이 문제라면 SubclassWindow()를 호출하기 전에 CWnd::FromHandlePermanent()를 통해 얻은 permanent CWnd 오브젝트의 Detach() 함수를 먼저 호출하여 MFC 윈도 맵의 충돌을 피할 수 있다.
그러나 이것보다 더 심각한 문제가 있다.
MFC는 모든 CWnd 클래스에 대해 AfxWndProc()이라는 윈도 프로시저를 사용하고 있다. 즉, 서브클래싱 하고자 하는 permanent 윈도 오브젝트도 AfxWndProc()을 사용하고 있고 서브클래싱된 CExWnd 클래스도 AfxWndProc()을 사용하고 있다. CWnd::SubclassWindow()는 파라미터로 전달된 윈도의 윈도 프로시저를 상위 윈도 프로시저(oldWndProc)로 저장하고 자신의 윈도 프로시저(AfxWndProc)를 설정한다. AfxWndProc()에서는 CallWindowProc()을 호출하여 서브클래싱 되기 전의 상위 윈도 프로시저(oldWndProc)를 호출한다. 그런데 서브클래싱된 CExWnd는 상위 윈도 프로시저(oldWndProc)에 AfxWndProc을 담고 있으므로 AfxWndProc이 무한 반복 호출되는 상황이 발생한다.
이것은 MFC의 내부 구조상 어쩔 수 없는 제약사항이다.
해결책은 두 가지가 있을 수 있다.
첫째는 범용 클래스를 소스 레벨에서 통합하지 말고 별도의 DLL로 빌드하여 통합할 것.
DLL로 빌드하면 CExWnd는 DLL 컨텍스트에서 관리되는 MFC 윈도 맵과 AfxWndProc을 사용하게 된다. 파라미터로 넘겨지는 윈도 핸들이 permanent CWnd 오브젝트의 핸들이라 할지라도 Exe 컨텍스트에서 사용되는 CWnd 오브젝트라면 충돌이 일어나지 않는다.
둘째는 직접 WNDPROC 타입의 콜백 함수(fnExWndProc)를 만들어 SetWindowLongPtr(hWnd, GWLP_WNDPROC, fnExWndProc)로 직접 서브클래싱할 것.
당연하다. MFC를 사용하지 않는 서브클래싱이면 아무 문제도 없다.
2009년 9월 16일 수요일
128-bit MMX
I’m quite sure that Intel would not like to see SSE2 named 128-bit MMX. In fact, MMX has a bad reputation: the Intel marketing hype pushed it as an universal solution to multimedia requirements, but at the same time the gaming industry switched from mostly 2D games to Virtual Reality-like 3D games that were not accelerated by MMX. Bad press coverage spread the news that MMX was meaningless as it did not improve the Quake frame-rate. That would be correct if the only applications worth running were 3D games, but the overly simplified vision of the world shared by most hardware sites missed several points: in fact, MMX instructions are constantly used to perform a wide array of tasks. PhotoShop users surely remember the performance boost given by MMX, but it should be made clear that each time you play a MP3, view a JPEG image in your browser or play a MPEG video a lot of MMX instructions are executed. Today all multimedia applications are built on MMX instructions, and they are the key to run computing-intensive tasks such as speech recognition on commonplace PCs.
Writing MMX code is still very hard, as you have to go back to assembler, but the performance benefits are rewarding. The support offered by current compilers is barebone. There are a few attempts to write C++ compilers that can automatically turn normal C code into vector MMX code, but they deal only with limited complexity loop vectorization and place too many constraints on the parallelizable code; in general, they appear notably less mature than vectorizing compilers available in the supercomputing domain.
So we cannot expect to have SSE2 enabled compilers anytime soon. This will not stop large companies that sell shrinkwrap software from exploiting SSE2 instructions as they can afford the required development time, but small-scale software firms are not likely to use SSE2 until the appearance of better development tools. In my opinion, the Pentium 4 scenario closely resembles the Pentium MMX one, where lack of software support made the additional investment for the Pentium MMX over plain old Pentium quite useless.
We have just analyzed the dark side of SSE2, i.e. difficult programming; now we can go on and delve into the technical details.
SSE2 extends MMX by using 128-bit registers instead of 64-bit ones, effectively doubling the level of parallelism. We may be tempted to replace MMX register names with SSE2 ones (e.g. turning MM0 into XMM0), recompile it and see it running at twice the speed. Unfortunately, it would not work, actually it would not even compile. These are the steps required to migrate MMX code to SSE2:
1) replace MMX register names with SSE2 ones, e.g. MM0 becomes XMM0;
2) replace MOVQ instructions with MOVAPD (if the memory address is 16-byte aligned) or MOVUPD (if the memory address is not aligned);
3) replace PSHUFW, which is a SSE extension to MMX, by a combination of the following instructions: PSHUFHW, PSHUFLW, PSHUFD;
4) replace PSLLQ and PSRLQ with PSLLDQ and PSRLDQ respectively;
5) update loop counters and numeric memory offsets, since we work on 128 bits at once instead of 64.
Looks easy, doesn’t it? Actually, it is not that simple. Replacing 64-bit shifts with 128-bit ones is trivial, but SSE2 expects memory references to be 16-byte aligned: while the MOVUPD instruction lets you load unaligned memory blocks at the expense of poor performance (so it should be not used unless strictly necessary), every instruction that uses a memory source operand, e.g. a PADDB MM0,[EAX], is a troublesome spot. Using unaligned memory references raises a General Protection fault, but avoiding GPF requires quite a lot of work. First of all, the memory allocators used in current compiler do not align data blocks on 16-bytes boundaries, so you will have to build a wrapper function around the malloc() function that allocates a slightly larger block than required and correctly aligns the resulting pointer (note: the Processor Pack for Visual C++ features an aligned_malloc() function that supports user-definable alignment of allocated blocks). Then you will have to find out all the lines in your source code where the code blocks that are processed with SSE2 instructions get allocated, and replace the standard allocation call with an invocation to your wrapper function: this is fairly easy if you have access to all the source code of your app, but impossible when third-party libraries allocate misaligned memory blocks; in this case, contact the software vendor and ask for an update.
If your MMX routine spills some variables onto the stack, we are in for more trouble, as we have to force the alignment of the stack, and it requires the modification of the entry and exit code of the routine.
The easiest way to fix a PSHUFW instruction is parting it in two, a PSHUFHW and a PSHUFLW, each operating respectively on the high and low 64-bit halves of the 128-bit register.
Here is the list of SSE2 instructions that extend MMX (adapted from Intel’s documentation):
Writing MMX code is still very hard, as you have to go back to assembler, but the performance benefits are rewarding. The support offered by current compilers is barebone. There are a few attempts to write C++ compilers that can automatically turn normal C code into vector MMX code, but they deal only with limited complexity loop vectorization and place too many constraints on the parallelizable code; in general, they appear notably less mature than vectorizing compilers available in the supercomputing domain.
So we cannot expect to have SSE2 enabled compilers anytime soon. This will not stop large companies that sell shrinkwrap software from exploiting SSE2 instructions as they can afford the required development time, but small-scale software firms are not likely to use SSE2 until the appearance of better development tools. In my opinion, the Pentium 4 scenario closely resembles the Pentium MMX one, where lack of software support made the additional investment for the Pentium MMX over plain old Pentium quite useless.
We have just analyzed the dark side of SSE2, i.e. difficult programming; now we can go on and delve into the technical details.
SSE2 extends MMX by using 128-bit registers instead of 64-bit ones, effectively doubling the level of parallelism. We may be tempted to replace MMX register names with SSE2 ones (e.g. turning MM0 into XMM0), recompile it and see it running at twice the speed. Unfortunately, it would not work, actually it would not even compile. These are the steps required to migrate MMX code to SSE2:
1) replace MMX register names with SSE2 ones, e.g. MM0 becomes XMM0;
2) replace MOVQ instructions with MOVAPD (if the memory address is 16-byte aligned) or MOVUPD (if the memory address is not aligned);
3) replace PSHUFW, which is a SSE extension to MMX, by a combination of the following instructions: PSHUFHW, PSHUFLW, PSHUFD;
4) replace PSLLQ and PSRLQ with PSLLDQ and PSRLDQ respectively;
5) update loop counters and numeric memory offsets, since we work on 128 bits at once instead of 64.
Looks easy, doesn’t it? Actually, it is not that simple. Replacing 64-bit shifts with 128-bit ones is trivial, but SSE2 expects memory references to be 16-byte aligned: while the MOVUPD instruction lets you load unaligned memory blocks at the expense of poor performance (so it should be not used unless strictly necessary), every instruction that uses a memory source operand, e.g. a PADDB MM0,[EAX], is a troublesome spot. Using unaligned memory references raises a General Protection fault, but avoiding GPF requires quite a lot of work. First of all, the memory allocators used in current compiler do not align data blocks on 16-bytes boundaries, so you will have to build a wrapper function around the malloc() function that allocates a slightly larger block than required and correctly aligns the resulting pointer (note: the Processor Pack for Visual C++ features an aligned_malloc() function that supports user-definable alignment of allocated blocks). Then you will have to find out all the lines in your source code where the code blocks that are processed with SSE2 instructions get allocated, and replace the standard allocation call with an invocation to your wrapper function: this is fairly easy if you have access to all the source code of your app, but impossible when third-party libraries allocate misaligned memory blocks; in this case, contact the software vendor and ask for an update.
If your MMX routine spills some variables onto the stack, we are in for more trouble, as we have to force the alignment of the stack, and it requires the modification of the entry and exit code of the routine.
The easiest way to fix a PSHUFW instruction is parting it in two, a PSHUFHW and a PSHUFLW, each operating respectively on the high and low 64-bit halves of the 128-bit register.
Here is the list of SSE2 instructions that extend MMX (adapted from Intel’s documentation):
2009년 9월 14일 월요일
load image from bitmap file
다음 코드에서는 LoadImage API 를 사용하여, DIBSection 같이 비트맵 로드 를, DIBSection 색 테이블에서 색상표를 만듭니다. 색 테이블이 있을 경우 하프톤 색상표가 사용됩니다:
BOOL LoadBitmapFromBMPFile( LPTSTR szFileName, HBITMAP *phBitmap,
HPALETTE *phPalette )
{
BITMAP bm;
*phBitmap = NULL;
*phPalette = NULL;
// Use LoadImage() to get the image loaded into a DIBSection
*phBitmap = (HBITMAP)LoadImage( NULL, szFileName, IMAGE_BITMAP, 0, 0,
LR_CREATEDIBSECTION | LR_DEFAULTSIZE | LR_LOADFROMFILE );
if( *phBitmap == NULL )
return FALSE;
// Get the color depth of the DIBSection
GetObject(*phBitmap, sizeof(BITMAP), &bm );
// If the DIBSection is 256 color or less, it has a color table
if( ( bm.bmBitsPixel * bm.bmPlanes ) <= 8 )
{
HDC hMemDC;
HBITMAP hOldBitmap;
RGBQUAD rgb[256];
LPLOGPALETTE pLogPal;
WORD i;
// Create a memory DC and select the DIBSection into it
hMemDC = CreateCompatibleDC( NULL );
hOldBitmap = (HBITMAP)SelectObject( hMemDC, *phBitmap );
// Get the DIBSection's color table
GetDIBColorTable( hMemDC, 0, 256, rgb );
// Create a palette from the color tabl
pLogPal = (LOGPALETTE *)malloc( sizeof(LOGPALETTE) + (256*sizeof(PALETTEENTRY)) );
pLogPal->palVersion = 0x300;
pLogPal->palNumEntries = 256;
for(i=0;i<256;i++)
{
pLogPal->palPalEntry[i].peRed = rgb[i].rgbRed;
pLogPal->palPalEntry[i].peGreen = rgb[i].rgbGreen;
pLogPal->palPalEntry[i].peBlue = rgb[i].rgbBlue;
pLogPal->palPalEntry[i].peFlags = 0;
}
*phPalette = CreatePalette( pLogPal );
// Clean up
free( pLogPal );
SelectObject( hMemDC, hOldBitmap );
DeleteDC( hMemDC );
}
else // It has no color table, so use a halftone palette
{
HDC hRefDC;
hRefDC = GetDC( NULL );
*phPalette = CreateHalftonePalette( hRefDC );
ReleaseDC( NULL, hRefDC );
}
return TRUE;
}
다음 코드에서는 LoadBitmapFromBMPFile 함수를 사용하여 방법을 보여 줍니다:
case WM_PAINT:
{
PAINTSTRUCT ps;
HBITMAP hBitmap, hOldBitmap;
HPALETTE hPalette, hOldPalette;
HDC hDC, hMemDC;
BITMAP bm;
hDC = BeginPaint( hWnd, &ps );
if( LoadBitmapFromBMPFile( szFileName, &hBitmap, &hPalette ) )
{
GetObject( hBitmap, sizeof(BITMAP), &bm );
hMemDC = CreateCompatibleDC( hDC );
hOldBitmap = (HBITMAP)SelectObject( hMemDC, hBitmap );
hOldPalette = SelectPalette( hDC, hPalette, FALSE );
RealizePalette( hDC );
BitBlt( hDC, 0, 0, bm.bmWidth, bm.bmHeight,
hMemDC, 0, 0, SRCCOPY );
SelectObject( hMemDC, hOldBitmap );
DeleteObject( hBitmap );
SelectPalette( hDC, hOldPalette, FALSE );
DeleteObject( hPalette );
}
EndPaint( hWnd, &ps );
}
break;
BOOL LoadBitmapFromBMPFile( LPTSTR szFileName, HBITMAP *phBitmap,
HPALETTE *phPalette )
{
BITMAP bm;
*phBitmap = NULL;
*phPalette = NULL;
// Use LoadImage() to get the image loaded into a DIBSection
*phBitmap = (HBITMAP)LoadImage( NULL, szFileName, IMAGE_BITMAP, 0, 0,
LR_CREATEDIBSECTION | LR_DEFAULTSIZE | LR_LOADFROMFILE );
if( *phBitmap == NULL )
return FALSE;
// Get the color depth of the DIBSection
GetObject(*phBitmap, sizeof(BITMAP), &bm );
// If the DIBSection is 256 color or less, it has a color table
if( ( bm.bmBitsPixel * bm.bmPlanes ) <= 8 )
{
HDC hMemDC;
HBITMAP hOldBitmap;
RGBQUAD rgb[256];
LPLOGPALETTE pLogPal;
WORD i;
// Create a memory DC and select the DIBSection into it
hMemDC = CreateCompatibleDC( NULL );
hOldBitmap = (HBITMAP)SelectObject( hMemDC, *phBitmap );
// Get the DIBSection's color table
GetDIBColorTable( hMemDC, 0, 256, rgb );
// Create a palette from the color tabl
pLogPal = (LOGPALETTE *)malloc( sizeof(LOGPALETTE) + (256*sizeof(PALETTEENTRY)) );
pLogPal->palVersion = 0x300;
pLogPal->palNumEntries = 256;
for(i=0;i<256;i++)
{
pLogPal->palPalEntry[i].peRed = rgb[i].rgbRed;
pLogPal->palPalEntry[i].peGreen = rgb[i].rgbGreen;
pLogPal->palPalEntry[i].peBlue = rgb[i].rgbBlue;
pLogPal->palPalEntry[i].peFlags = 0;
}
*phPalette = CreatePalette( pLogPal );
// Clean up
free( pLogPal );
SelectObject( hMemDC, hOldBitmap );
DeleteDC( hMemDC );
}
else // It has no color table, so use a halftone palette
{
HDC hRefDC;
hRefDC = GetDC( NULL );
*phPalette = CreateHalftonePalette( hRefDC );
ReleaseDC( NULL, hRefDC );
}
return TRUE;
}
다음 코드에서는 LoadBitmapFromBMPFile 함수를 사용하여 방법을 보여 줍니다:
case WM_PAINT:
{
PAINTSTRUCT ps;
HBITMAP hBitmap, hOldBitmap;
HPALETTE hPalette, hOldPalette;
HDC hDC, hMemDC;
BITMAP bm;
hDC = BeginPaint( hWnd, &ps );
if( LoadBitmapFromBMPFile( szFileName, &hBitmap, &hPalette ) )
{
GetObject( hBitmap, sizeof(BITMAP), &bm );
hMemDC = CreateCompatibleDC( hDC );
hOldBitmap = (HBITMAP)SelectObject( hMemDC, hBitmap );
hOldPalette = SelectPalette( hDC, hPalette, FALSE );
RealizePalette( hDC );
BitBlt( hDC, 0, 0, bm.bmWidth, bm.bmHeight,
hMemDC, 0, 0, SRCCOPY );
SelectObject( hMemDC, hOldBitmap );
DeleteObject( hBitmap );
SelectPalette( hDC, hOldPalette, FALSE );
DeleteObject( hPalette );
}
EndPaint( hWnd, &ps );
}
break;
2009년 7월 30일 목요일
The three-way TCP handshake
1. The three-way TCP handshake
TCP는 장치들 사이에 논리적인 접속을 성립(establish)하기 위하여 three-way handshake를 사용한다.
Client > Server : TCP SYN
Server > Client : TCP SYN ACK
Client > Server : TCP ACK
여기서 SYN은 'synchronize sequence numbers', 그리고 ACK는'acknowledgment' 의 약자이다.
이러한 절차는 TCP 접속을 성공적으로 성립하기 위하여 반드시 필요하다.
TCP는 windowing을 사용한다. (윈도우는 현재 네트웍 혼잡과 수신 용량에 따라 조정될 수 있는 데이터 플로우의 크기)
구체적인 TCP three-way handshake 과정은 다음과 같다.
요청 단말(클라이언트)은 아래 요소를 포함하는 서버에게 TCP SYN 세그먼트를 송신
- 연결하고자하는 서버의 포트 번호
- 클라이언트의 Initial Sequence Number (ISN)
- 옵션인 Maximum Segment Size (MSS)
TCP SYN ACK
아래와 같은 SYN 정보를 가지고 있는 서버의 응답
- 서버의 ISN
- 클라이언트의 ISN + 1 에 대한 ACK
- 옵션인Maximum Segment Size (MSS)
서버의 SYN에 대한 클라이언트의 ACK (TCP ACK)
- 서버의 ISN + 1
TCP를 통한 TCP 통신의 분석 시, MSS는 접속 시간내에 다른 peer에게 알리며 MSS 옵션이 존재하지 않으면 디폴트로 536 bytes를 사용한다는 사실을 명심하라. (이것은 디폴트 IP MTU인 576 bytes에서 20-bytes의 IP 헤더와 20-bytes의 TCP 헤더를 뺀 값이다.)
옵션인 MSS는 일반적으로 클라이언트와 서버 사이의 경로상의 Maximum Transmission Unit (MTU)에서 프로토콜 오버헤드를 뺀 값이며 네트웍 계층(Network Layer)의 헤더를 포함하고 있다. 예를 들어, Ethernet에 대하여 요구되는 MSS는 1460 bytes 이며 IEEE 802.3에 대한 것은 1452 bytes 이다. (8-byte LLC SNAP 헤더에 대해 허용) 만약 어떤 네트웍 사이에 MTU에 대해 세그먼트 크기가 너무 크면 필요하지 않은 IP Fragmentation이 발생할 수 있음에 주의하라.
2. TCP Retransmissions
TCP는 수신자의 윈도우 크기에 따라서 다수의 TCP 패킷의 데이터를 전송하므로써 시작되며 그 후 수신자의 ACK로 인지 되는 버퍼 크기 (윈도우 크기)가 허용되는 비율만큼 계속 전송된다. 만약, TCP 세그먼트가 ACK를 보지 못하고 패킷이 유실되면 송신자는 반드시 어떤 순차적인 세그먼트를 다시 보내야 한다.
하나의 TCP 패킷 재전송에 대해 오직 하나의 패킷에 대한 재전송으로 생각하지는 말아야 한다.
패킷 재전송이 발생하는 상황:
- 전송되는 TCP 세그먼트 혹은 되돌아온 ACK가 스위치나 라우터에의해서 유실(dropped) 되는 경우
- 패킷이 전송하는 동안 손상될 때 (패킷은 CRC 에러를 가지고 있음)
- 패킷의 TCP 데이터 부분이 스위치나 라우터에 의해서 손상될 때 (TCP 체크섬 에러 발생)
- 수신자가 패킷을 버퍼에 저장할 수 없을 때
- TCP 세그먼트가 fragment 되고 fragment가 손상되거나 유실(dropped) 될때
- ACK가 너무 늦게 돌아오고 송신자가 하나 혹은 그 이상의 세그먼트를 재전송 할 때
만약 ACK가 수신되지 않으면 세그먼트는 다시 전송하지만, 첫번째 대기 시간의 두배가 된다. TCP 스택은 접속의 round-trip 시간에 기반하여 초기 timeout 값을 동적으로 계산한다.
각각의 성공적인 재전송은 이전 시도의 두배로 연기된다. TCP 스택은 포기하기 전에 미리 정해진 시간 만큼 시도할 것이다. Windows 95/98/NT 상에서 디폴트 설정은 5번의 시도로 레지스트리상에 설정되어 있다.
Expert 시스템이 없는 Protocol Analyzer를 가지고도 다음과 같은 3가지 순서에 따라 TCP에 대한 재전송을 인지할 수 있다:
1. Analyzer상의 display 버퍼상에서 통신하고 있는 IP 스테이션의 두쌍에 대한 필터를 적용한다.
2. 성공적으로 전송된 패킷 사이의 대략 1초의 3/1에 해당하는 long delay 혹은 주요 delay들을 관찰한다.
3. 이전의 패킷에 대하여 전송하는 sequence number를 체크한다. 오리지널 전송은 몇몇 패킷 이전에 위치할 수 있음을 명심하라.
각각의 패킷 사이의 long delta 시간과 주요 delay에 지시되는 패킷 재전송을 확인하라.
이러한 패킷 재전송의 증거는 패킷내의 sequence number가 동일하다는 것으로 증명될 수 있다.
즉, TCP 패킷 재전송은 sequence number가 동일하다는 것을 의미한다.
몇 가지 경우, Analyzer의 버퍼 상에 왜 그런 재전송이 발생하는 지에 대한 실마리가 이미 있다.
몇 가지 가능성은 다음과 같다.
- 이전 세그먼트 혹은 ACK가 CRC 에러를 가지고 있다. (Ethernet상)
- 토큰링 상에서 스테이션은 대략 2초후에 소프트 에러를 보고한다.
- 이전 패킷에서 TCP 체크섬 에러는 가능한 브리지 혹은 라우터의 손상을 나타낸다.
- 이전 패킷의 TCP 체크섬 에러와 현재 패킷이 재전송되고 있는 패킷은 송신자의 문제이다.
(예를 들면, TCP 체크섬이 올바르게 계산되지 않을 수 있다.)
- 유실되거나 지연된 ACK가 있을 경우, Analyzer를 다른 세그먼트로 이동해서 문제를 정확하게 지적해야 한다.
만약 특정한 서버나 워크스테이션에서 많은 수의 TCP 재전송이 의심되면 netstat 명령어를 사용할 수 있다.
Netstat 명령의 일반적인 사용 (netstat –r)으로 라우팅 정보 - 예를들면 서버나 워크스테이션과 연관된 라우터등의 route table 등 – 를 얻을 수 있다. Netstat 명령으로 또한 “netstat –s” 를 사용하여 프로토콜 통계에 대한 정보를 얻을 수 있다. Windows 95/98/NT 상의 netstat 명령은 특정 프로토콜을 명시할 수 있다.
예를 들어, “netstat –s –p tcp” 명령으로 특정 NT 서버의 대한 다음과 같은 정보들을 얻을 수 있다.
C:\>netstat -s -p tcp
TCP Statistics
Active Opens = 204
Passive Opens = 734
Failed Connection Attempts = 2
Reset Connections = 263
Current Connections = 2
Segments Received = 44759
Segments Sent = 26058
Segments Retransmitted = 15
Active Connections
Proto Local Address Foreign Address State
TCP elca:1123 64.12.24.58:5190 ESTABLISHED
TCP elca:1549 203.231.222.194:pop3 TIME_WAIT
TCP elca:1553 203.231.222.194:pop3 TIME_WAIT
TCP elca:31510 test.iworld.co.kr:1635 ESTABLISHED
상기의 netstat 예제를 보면 26,058 TCP 세그먼트가 서버에 의해 전송되었고 15개의 재전송이 발생하였다. 이것은 1% 보다 작다. 만약, 퍼센트가 이보다 높아진다면 다음 순서는 Analyzer를 서버의 세그먼트에 Tapping하여 TCP 트래픽을 조사해야 한다.
Sniffer Expert System의 Symptom 설명
- 중요도: Minor
- 현상: 이 메시지는 특정 TCP 패킷의 순서 번호가 이전 것과 비교하여 같거나 작을 때, 즉 이전에 이미 전송된 TCP 세그먼트에 대한 ACK 신호를 받지 못하여 시간 초과 상태에서 해제되었던 패킷이 재전송되었을 경우에 발생한다.
- 원인:
. 네트웍 트래픽이 정체되었을 경우
. 수신하는 스테이션이 과부하 상태인 경우
. 라우터가 과부하 상태이거나 다른 이유로 전송지연이 되는 경우
. ACK 신호를 보냈지만 유실된 경우
. 원래의 패킷이나 ACK이 손상을 입은 경우
. ACK 신호가 늦은 경로로 전송되었을 경우
. IP 조각을 읽어버린 경우
. 네트워크의 완결성에 문제가 있는 경우
[이 게시물은 아쿠아님에 의해 2007-05-05 13:51:42 시스템자료실에서 이동 됨]
TCP는 장치들 사이에 논리적인 접속을 성립(establish)하기 위하여 three-way handshake를 사용한다.
Client > Server : TCP SYN
Server > Client : TCP SYN ACK
Client > Server : TCP ACK
여기서 SYN은 'synchronize sequence numbers', 그리고 ACK는'acknowledgment' 의 약자이다.
이러한 절차는 TCP 접속을 성공적으로 성립하기 위하여 반드시 필요하다.
TCP는 windowing을 사용한다. (윈도우는 현재 네트웍 혼잡과 수신 용량에 따라 조정될 수 있는 데이터 플로우의 크기)
구체적인 TCP three-way handshake 과정은 다음과 같다.
요청 단말(클라이언트)은 아래 요소를 포함하는 서버에게 TCP SYN 세그먼트를 송신
- 연결하고자하는 서버의 포트 번호
- 클라이언트의 Initial Sequence Number (ISN)
- 옵션인 Maximum Segment Size (MSS)
TCP SYN ACK
아래와 같은 SYN 정보를 가지고 있는 서버의 응답
- 서버의 ISN
- 클라이언트의 ISN + 1 에 대한 ACK
- 옵션인Maximum Segment Size (MSS)
서버의 SYN에 대한 클라이언트의 ACK (TCP ACK)
- 서버의 ISN + 1
TCP를 통한 TCP 통신의 분석 시, MSS는 접속 시간내에 다른 peer에게 알리며 MSS 옵션이 존재하지 않으면 디폴트로 536 bytes를 사용한다는 사실을 명심하라. (이것은 디폴트 IP MTU인 576 bytes에서 20-bytes의 IP 헤더와 20-bytes의 TCP 헤더를 뺀 값이다.)
옵션인 MSS는 일반적으로 클라이언트와 서버 사이의 경로상의 Maximum Transmission Unit (MTU)에서 프로토콜 오버헤드를 뺀 값이며 네트웍 계층(Network Layer)의 헤더를 포함하고 있다. 예를 들어, Ethernet에 대하여 요구되는 MSS는 1460 bytes 이며 IEEE 802.3에 대한 것은 1452 bytes 이다. (8-byte LLC SNAP 헤더에 대해 허용) 만약 어떤 네트웍 사이에 MTU에 대해 세그먼트 크기가 너무 크면 필요하지 않은 IP Fragmentation이 발생할 수 있음에 주의하라.
2. TCP Retransmissions
TCP는 수신자의 윈도우 크기에 따라서 다수의 TCP 패킷의 데이터를 전송하므로써 시작되며 그 후 수신자의 ACK로 인지 되는 버퍼 크기 (윈도우 크기)가 허용되는 비율만큼 계속 전송된다. 만약, TCP 세그먼트가 ACK를 보지 못하고 패킷이 유실되면 송신자는 반드시 어떤 순차적인 세그먼트를 다시 보내야 한다.
하나의 TCP 패킷 재전송에 대해 오직 하나의 패킷에 대한 재전송으로 생각하지는 말아야 한다.
패킷 재전송이 발생하는 상황:
- 전송되는 TCP 세그먼트 혹은 되돌아온 ACK가 스위치나 라우터에의해서 유실(dropped) 되는 경우
- 패킷이 전송하는 동안 손상될 때 (패킷은 CRC 에러를 가지고 있음)
- 패킷의 TCP 데이터 부분이 스위치나 라우터에 의해서 손상될 때 (TCP 체크섬 에러 발생)
- 수신자가 패킷을 버퍼에 저장할 수 없을 때
- TCP 세그먼트가 fragment 되고 fragment가 손상되거나 유실(dropped) 될때
- ACK가 너무 늦게 돌아오고 송신자가 하나 혹은 그 이상의 세그먼트를 재전송 할 때
만약 ACK가 수신되지 않으면 세그먼트는 다시 전송하지만, 첫번째 대기 시간의 두배가 된다. TCP 스택은 접속의 round-trip 시간에 기반하여 초기 timeout 값을 동적으로 계산한다.
각각의 성공적인 재전송은 이전 시도의 두배로 연기된다. TCP 스택은 포기하기 전에 미리 정해진 시간 만큼 시도할 것이다. Windows 95/98/NT 상에서 디폴트 설정은 5번의 시도로 레지스트리상에 설정되어 있다.
Expert 시스템이 없는 Protocol Analyzer를 가지고도 다음과 같은 3가지 순서에 따라 TCP에 대한 재전송을 인지할 수 있다:
1. Analyzer상의 display 버퍼상에서 통신하고 있는 IP 스테이션의 두쌍에 대한 필터를 적용한다.
2. 성공적으로 전송된 패킷 사이의 대략 1초의 3/1에 해당하는 long delay 혹은 주요 delay들을 관찰한다.
3. 이전의 패킷에 대하여 전송하는 sequence number를 체크한다. 오리지널 전송은 몇몇 패킷 이전에 위치할 수 있음을 명심하라.
각각의 패킷 사이의 long delta 시간과 주요 delay에 지시되는 패킷 재전송을 확인하라.
이러한 패킷 재전송의 증거는 패킷내의 sequence number가 동일하다는 것으로 증명될 수 있다.
즉, TCP 패킷 재전송은 sequence number가 동일하다는 것을 의미한다.
몇 가지 경우, Analyzer의 버퍼 상에 왜 그런 재전송이 발생하는 지에 대한 실마리가 이미 있다.
몇 가지 가능성은 다음과 같다.
- 이전 세그먼트 혹은 ACK가 CRC 에러를 가지고 있다. (Ethernet상)
- 토큰링 상에서 스테이션은 대략 2초후에 소프트 에러를 보고한다.
- 이전 패킷에서 TCP 체크섬 에러는 가능한 브리지 혹은 라우터의 손상을 나타낸다.
- 이전 패킷의 TCP 체크섬 에러와 현재 패킷이 재전송되고 있는 패킷은 송신자의 문제이다.
(예를 들면, TCP 체크섬이 올바르게 계산되지 않을 수 있다.)
- 유실되거나 지연된 ACK가 있을 경우, Analyzer를 다른 세그먼트로 이동해서 문제를 정확하게 지적해야 한다.
만약 특정한 서버나 워크스테이션에서 많은 수의 TCP 재전송이 의심되면 netstat 명령어를 사용할 수 있다.
Netstat 명령의 일반적인 사용 (netstat –r)으로 라우팅 정보 - 예를들면 서버나 워크스테이션과 연관된 라우터등의 route table 등 – 를 얻을 수 있다. Netstat 명령으로 또한 “netstat –s” 를 사용하여 프로토콜 통계에 대한 정보를 얻을 수 있다. Windows 95/98/NT 상의 netstat 명령은 특정 프로토콜을 명시할 수 있다.
예를 들어, “netstat –s –p tcp” 명령으로 특정 NT 서버의 대한 다음과 같은 정보들을 얻을 수 있다.
C:\>netstat -s -p tcp
TCP Statistics
Active Opens = 204
Passive Opens = 734
Failed Connection Attempts = 2
Reset Connections = 263
Current Connections = 2
Segments Received = 44759
Segments Sent = 26058
Segments Retransmitted = 15
Active Connections
Proto Local Address Foreign Address State
TCP elca:1123 64.12.24.58:5190 ESTABLISHED
TCP elca:1549 203.231.222.194:pop3 TIME_WAIT
TCP elca:1553 203.231.222.194:pop3 TIME_WAIT
TCP elca:31510 test.iworld.co.kr:1635 ESTABLISHED
상기의 netstat 예제를 보면 26,058 TCP 세그먼트가 서버에 의해 전송되었고 15개의 재전송이 발생하였다. 이것은 1% 보다 작다. 만약, 퍼센트가 이보다 높아진다면 다음 순서는 Analyzer를 서버의 세그먼트에 Tapping하여 TCP 트래픽을 조사해야 한다.
Sniffer Expert System의 Symptom 설명
- 중요도: Minor
- 현상: 이 메시지는 특정 TCP 패킷의 순서 번호가 이전 것과 비교하여 같거나 작을 때, 즉 이전에 이미 전송된 TCP 세그먼트에 대한 ACK 신호를 받지 못하여 시간 초과 상태에서 해제되었던 패킷이 재전송되었을 경우에 발생한다.
- 원인:
. 네트웍 트래픽이 정체되었을 경우
. 수신하는 스테이션이 과부하 상태인 경우
. 라우터가 과부하 상태이거나 다른 이유로 전송지연이 되는 경우
. ACK 신호를 보냈지만 유실된 경우
. 원래의 패킷이나 ACK이 손상을 입은 경우
. ACK 신호가 늦은 경로로 전송되었을 경우
. IP 조각을 읽어버린 경우
. 네트워크의 완결성에 문제가 있는 경우
[이 게시물은 아쿠아님에 의해 2007-05-05 13:51:42 시스템자료실에서 이동 됨]
2009년 7월 21일 화요일
volatile에 대한 오해
“임베디드 프로그래밍 C 코드 최적화” 라는 책을 살짝 보는데 다음 내용에 오류가 있다. 아무리 이 책이 임베디드 환경을 기본으로 했다 하더라도 혼동하기 쉬운 내용이다. 53, 54, 57페이지의 내용이다. 책 내용 일부를 무단 전제하는 것이라 저작권에 위배되는 것 같지만 그래도 좋은 일(?) 하기에 양해 좀 부탁 드립니다.
1: // 하드웨어가 사용하는 메모리는 volatile로 선언2: #define TEMP (*(volatile unsigned int *) 0x001)3: void main(void) {4: int a[10], i;5: volatile int j; // 지연 루프에서 사용하는 변수를 volatile로 선언하여6: // 컴파일러가 최적화시 코드 제거를 막을 수 있도록 함.7: TEMP = 0x0;8: for(i = 0; i < 10; i++) {9: a[i] = TEMP;10: for(j = 0; j < 100000; j++);11: }12: for(i = 0; i < 10; i++)13: printf("a[%d] = %d \t", i, a[i]);14: }
volatile의 기능적 의미는 캐시사용안함(no-cache)이다. 보통 프로그램이 실행될 때 속도를 위해 필요한 데이터를 메모리에서 직접 읽어오지 않고 캐시(cache)로부터 읽어온다. 하지만, 하드웨어에 의해서 변경되는 값들은 캐시에 즉각적으로 반영되지 않으므로 데이터를 캐시로부터 읽어오지 말고 주 메모리에서 직접 읽어오도록 해야한다. 이러한 특성 때문에 하드웨어가 사용하는 메모리는 volatile로 선언해야 하드웨어에 의해 변경된 값들이 프로그램에 제대로 반영된다.
volatile은 임베디드 소프트웨어에서 자주 사용하는 타입 한정자이다. 하드웨어가 사용하는 메모리 선언 시에 주로 사용되고, 최적화 금지의 용도로도 쓰인다. 기본개념은 캐시사용금지(no-cache)로 변경된 값을 즉각 주 메모리에서 읽을 수 있게 하여 프로그램에서 하드웨어의 번화를 감지할 수 있도록 하는 것이다.
(볼드체는 내가 강조한 것임)
전형적으로 volatile에 대해 오해하고 있는 두 가지 사실이다. 일단 임베디드 환경 뿐만 아니라 보통 PC 같은 컴퓨터도 생각해보자.
C/C++가 정의하는 volatile 키워드는 이 값이 언제든지 변할 수 있기에 컴파일러는 최적화하지 말 것을 지시한다. 따라서 이 의미가 직접적으로 “캐시 사용 안 함”은 아니지만 간접적으로 이 말은 맞다. 그런데 여기서 말하는 캐시는 CPU 내의 L1/L2 캐시가 아니라, 레지스터를 가리킨다. 컴파일러는 보통 최적화를 할 때 최대한 변수가 레지스터에 머물도록 한다. 그러니까 변수를 레지스터에 ‘캐싱’하는 것이다 (추가: 레지스터에 캐시한다는 내용은 댓글을 봐주시면 고맙겠습니다. 보통 이런 경우, 레지스터에 캐시한다고 표현하지는 않지만, 반복적인 메모리 접근 대신 레지스터에 그 값을 두고 쓴다는 의미에서 캐시라는 표현을 썼습니다)
그런데 레지스터는 스레드마다 유효한 문맥(context)이므로 (다른 스레드에 유효한 레지스터 값을 쉽게 보는 방법은 없다) 다른 스레드가 이 변수 값을 수정한다면, 레지스터에 캐시된 값은 최신 값을 반영하지 못한다. 이런 문제를 막기 위해 volatile 키워드를 사용한다. 이 키워드는 컴파일러가 이런 레지스터에 캐시하는 것을 막으므로 항상 메모리에서 값을 읽도록 한다. 그런데 잠깐, 여기서 메모리는 정확하게 무엇을 가리키는가?
여기서 또 혼동하는 것이 volatile로 선언된 변수가 CPU 내의 캐시를 다 무시하고 바로 메모리(=DRAM 주메모리)에서 읽고 쓴다는 것이다. 캐시의 의미를 CPU 캐시로 오해했기 때문에 이런 이야기를 하게 된다.
일반적으로 이 말은 옳지 않다. 아무리 volatile을 붙인 변수라도 여전히 CPU 캐시를 활용한다. CPU의 캐시는 보통 사용자에게 투명(transparent)하므로 캐시가 있으나 없으나 밖에서는 구분하기 어렵다. 보통 특수한 명령어1)를 쓰지 않은 이상 데이터를 읽으면 CPU가 알아서 L1/L2 캐시에 있으면 거기서 값을 주고 없으면 DRAM까지 갔다 온 뒤 값을 돌려준다. 물론 세밀한 성능을 위해 캐시를 제어할 수 있는 명령도 제공된다.
그런데 예외가 있다. x86 CPU에는 특정 메모리 주소 영역을 캐시 가능하지 않도록 설정할 수 있다. MTTRs(Memory Type Range Registers)과 PAT(Page Attribute Table)라는 기능으로 캐시 가능한 부분과 그러하지 않은 부분을 제어할 수 있다. 보통 운영체제가 부팅 시 처음으로 설정한다고 한다.
프로그램이 쓰는 일반적인 메모리 영역은 당연히 캐시 가능할 것이다. 그래서 멀티 코어 환경에서도 CPU가 잘 알아서 캐시 코히런스를 맞춰주기 때문에 프로그램은 큰 걱정하지 않고 데이터를 읽어도 그 값의 정확함을 기대할 수 있다. (물론 여기에도 또 예외가 있다. 메모리 컨시스턴시, consistency 문제를 고려 하지 않으면 부정확한 값을 읽을 수 있다. 이 이야기는 복잡함으로 생략.)
그런데 이 책에서는 LED를 깜빡이게 하는 하드웨어를 제어하는 C코드를 예로 들면서 volatile을 설명하고 있다. 보통 이런 하드웨어에 접근할 때는 memory mapped I/O을 이용한다. 그냥 메모리에 값을 쓰는 것으로 바로 하드웨어에 접근한다. 이런 하드웨어에 매핑된 주소는 보통 캐시 되지 않도록 한다. 따라서 volatile로 선언된 주소가 캐시 불가능한 메모리 영역이라면 바로 주 메모리 혹은 해당 하드웨어 장치로 왔다 갔다 한다. 그렇다면 이 책의 설명은 어느 정도 옳다. 그러나 이런 내용이 나와있지 않아 초보자에게 혼동을 심어줄 수 있다.
실제 이 책에서는, 맨 위의 코드처럼, 하드웨어 주소를 가리키는 변수 하나와 단순히 시간 지연을 위한 for 루프의 변수 j, 이 두 개에 volatile을 붙인 뒤 설명하고 있다. 전자는 volatile로 바로 메모리에서 읽는 것이 말이 되지만 j의 경우에는 volatile이 있더라도 (일반적으로) CPU 캐시를 바로 지나치게 할 수는 없다.
요약하면:
C/C++ volatile 키워드는 컴파일러 최적화를 막아 레지스터에 그 값이 캐시되지 않도록 하여 바로 메모리의 값을 읽도록 하는데, 일반적인 경우, 여전히 CPU 내의 캐시는 최대한 활용된다.
volatile 키워드는 컴파일러, 프로그래밍 언어마다 그 뜻이 다르므로 잘 알아보고 쓰자. 특히 멀티스레디드 환경에서 함부로 쓰는 것은 주의해야 한다.
혹시 제가 잘못 알고 있다면 꼭 좀 알려주세요.
CPU는 x86 같은 범용 프로세서도 있지만 임베디드 환경에 쓰이는 특수한 녀석도 많다. 그래서 volatile 키워드 하나를 설명하는데도 많은 경우의 수를 고려해야 한다. 또, CPU도 시간이 지남에 따라 여러 기능이 추가 되기 때문에 단정적으로 말하기가 매우 힘들다. 그래서 “volatile이 있더라도 (일반적으로) CPU 캐시를..” 라는 문장처럼 “(일반적으로)”라는 작은 구멍을 만들었다. 나도 언제든지 틀릴 수 있기 때문이다.
1) x86에서 메모리(다시 한번, 여기서 말하는 메모리는 L1/L2/L3 및 주 메모리를 통칭하는 메모리 계층을 뜻) 읽기(load)와 쓰기(store) 명령은 CPU가 알아서 처리하므로 캐시를 지나치게 할 수는 없다. 그러나 SSE2 확장부터는 쓰기(store)에 대해서 캐시 오염(cache pollution)을 최소화 하는 명령을 지원한다. 그러니까 데이터를 쓸 때 캐시를 건들이지 않고 바로 메모리에 쓰는 것이다. 이런 메모리 명령을 non-temporal이라 부른다. Non-temporal 쓰기에는 MOVNTI, MOVNTDQ, MOVNTPD가 있다. 이게 왜 멀티미디어 확장 같은 SSE 명령에 있냐면, 이런 non-temporal 쓰기는 그래픽 비디오 메모리에 효과적으로 대역폭을 높일 수 있다.
반면, non-temporal 읽기는 SSE4.1에 와서 하나 추가 되었다. 바로 streaming read를 목적으로 하는 MOVNTDQA라는 명령어다. 이 명령은 캐시 가능하지 않은 메모리 영역에 대해 (또 weakly ordering을 지원하는 영역, 이 부분은 설명하고 싶지만 너무 글이 길어져 생략) 높은 대역폭으로, 캐시를 건들이지 않고, 데이터를 바로 레지스터로 가져오게 한다. 차차세대 인텔 CPU에는 이런 non-temporal 읽기가 더욱 강화된다고 한다.
1: // 하드웨어가 사용하는 메모리는 volatile로 선언2: #define TEMP (*(volatile unsigned int *) 0x001)3: void main(void) {4: int a[10], i;5: volatile int j; // 지연 루프에서 사용하는 변수를 volatile로 선언하여6: // 컴파일러가 최적화시 코드 제거를 막을 수 있도록 함.7: TEMP = 0x0;8: for(i = 0; i < 10; i++) {9: a[i] = TEMP;10: for(j = 0; j < 100000; j++);11: }12: for(i = 0; i < 10; i++)13: printf("a[%d] = %d \t", i, a[i]);14: }
volatile의 기능적 의미는 캐시사용안함(no-cache)이다. 보통 프로그램이 실행될 때 속도를 위해 필요한 데이터를 메모리에서 직접 읽어오지 않고 캐시(cache)로부터 읽어온다. 하지만, 하드웨어에 의해서 변경되는 값들은 캐시에 즉각적으로 반영되지 않으므로 데이터를 캐시로부터 읽어오지 말고 주 메모리에서 직접 읽어오도록 해야한다. 이러한 특성 때문에 하드웨어가 사용하는 메모리는 volatile로 선언해야 하드웨어에 의해 변경된 값들이 프로그램에 제대로 반영된다.
volatile은 임베디드 소프트웨어에서 자주 사용하는 타입 한정자이다. 하드웨어가 사용하는 메모리 선언 시에 주로 사용되고, 최적화 금지의 용도로도 쓰인다. 기본개념은 캐시사용금지(no-cache)로 변경된 값을 즉각 주 메모리에서 읽을 수 있게 하여 프로그램에서 하드웨어의 번화를 감지할 수 있도록 하는 것이다.
(볼드체는 내가 강조한 것임)
전형적으로 volatile에 대해 오해하고 있는 두 가지 사실이다. 일단 임베디드 환경 뿐만 아니라 보통 PC 같은 컴퓨터도 생각해보자.
C/C++가 정의하는 volatile 키워드는 이 값이 언제든지 변할 수 있기에 컴파일러는 최적화하지 말 것을 지시한다. 따라서 이 의미가 직접적으로 “캐시 사용 안 함”은 아니지만 간접적으로 이 말은 맞다. 그런데 여기서 말하는 캐시는 CPU 내의 L1/L2 캐시가 아니라, 레지스터를 가리킨다. 컴파일러는 보통 최적화를 할 때 최대한 변수가 레지스터에 머물도록 한다. 그러니까 변수를 레지스터에 ‘캐싱’하는 것이다 (추가: 레지스터에 캐시한다는 내용은 댓글을 봐주시면 고맙겠습니다. 보통 이런 경우, 레지스터에 캐시한다고 표현하지는 않지만, 반복적인 메모리 접근 대신 레지스터에 그 값을 두고 쓴다는 의미에서 캐시라는 표현을 썼습니다)
그런데 레지스터는 스레드마다 유효한 문맥(context)이므로 (다른 스레드에 유효한 레지스터 값을 쉽게 보는 방법은 없다) 다른 스레드가 이 변수 값을 수정한다면, 레지스터에 캐시된 값은 최신 값을 반영하지 못한다. 이런 문제를 막기 위해 volatile 키워드를 사용한다. 이 키워드는 컴파일러가 이런 레지스터에 캐시하는 것을 막으므로 항상 메모리에서 값을 읽도록 한다. 그런데 잠깐, 여기서 메모리는 정확하게 무엇을 가리키는가?
여기서 또 혼동하는 것이 volatile로 선언된 변수가 CPU 내의 캐시를 다 무시하고 바로 메모리(=DRAM 주메모리)에서 읽고 쓴다는 것이다. 캐시의 의미를 CPU 캐시로 오해했기 때문에 이런 이야기를 하게 된다.
일반적으로 이 말은 옳지 않다. 아무리 volatile을 붙인 변수라도 여전히 CPU 캐시를 활용한다. CPU의 캐시는 보통 사용자에게 투명(transparent)하므로 캐시가 있으나 없으나 밖에서는 구분하기 어렵다. 보통 특수한 명령어1)를 쓰지 않은 이상 데이터를 읽으면 CPU가 알아서 L1/L2 캐시에 있으면 거기서 값을 주고 없으면 DRAM까지 갔다 온 뒤 값을 돌려준다. 물론 세밀한 성능을 위해 캐시를 제어할 수 있는 명령도 제공된다.
그런데 예외가 있다. x86 CPU에는 특정 메모리 주소 영역을 캐시 가능하지 않도록 설정할 수 있다. MTTRs(Memory Type Range Registers)과 PAT(Page Attribute Table)라는 기능으로 캐시 가능한 부분과 그러하지 않은 부분을 제어할 수 있다. 보통 운영체제가 부팅 시 처음으로 설정한다고 한다.
프로그램이 쓰는 일반적인 메모리 영역은 당연히 캐시 가능할 것이다. 그래서 멀티 코어 환경에서도 CPU가 잘 알아서 캐시 코히런스를 맞춰주기 때문에 프로그램은 큰 걱정하지 않고 데이터를 읽어도 그 값의 정확함을 기대할 수 있다. (물론 여기에도 또 예외가 있다. 메모리 컨시스턴시, consistency 문제를 고려 하지 않으면 부정확한 값을 읽을 수 있다. 이 이야기는 복잡함으로 생략.)
그런데 이 책에서는 LED를 깜빡이게 하는 하드웨어를 제어하는 C코드를 예로 들면서 volatile을 설명하고 있다. 보통 이런 하드웨어에 접근할 때는 memory mapped I/O을 이용한다. 그냥 메모리에 값을 쓰는 것으로 바로 하드웨어에 접근한다. 이런 하드웨어에 매핑된 주소는 보통 캐시 되지 않도록 한다. 따라서 volatile로 선언된 주소가 캐시 불가능한 메모리 영역이라면 바로 주 메모리 혹은 해당 하드웨어 장치로 왔다 갔다 한다. 그렇다면 이 책의 설명은 어느 정도 옳다. 그러나 이런 내용이 나와있지 않아 초보자에게 혼동을 심어줄 수 있다.
실제 이 책에서는, 맨 위의 코드처럼, 하드웨어 주소를 가리키는 변수 하나와 단순히 시간 지연을 위한 for 루프의 변수 j, 이 두 개에 volatile을 붙인 뒤 설명하고 있다. 전자는 volatile로 바로 메모리에서 읽는 것이 말이 되지만 j의 경우에는 volatile이 있더라도 (일반적으로) CPU 캐시를 바로 지나치게 할 수는 없다.
요약하면:
C/C++ volatile 키워드는 컴파일러 최적화를 막아 레지스터에 그 값이 캐시되지 않도록 하여 바로 메모리의 값을 읽도록 하는데, 일반적인 경우, 여전히 CPU 내의 캐시는 최대한 활용된다.
volatile 키워드는 컴파일러, 프로그래밍 언어마다 그 뜻이 다르므로 잘 알아보고 쓰자. 특히 멀티스레디드 환경에서 함부로 쓰는 것은 주의해야 한다.
혹시 제가 잘못 알고 있다면 꼭 좀 알려주세요.
CPU는 x86 같은 범용 프로세서도 있지만 임베디드 환경에 쓰이는 특수한 녀석도 많다. 그래서 volatile 키워드 하나를 설명하는데도 많은 경우의 수를 고려해야 한다. 또, CPU도 시간이 지남에 따라 여러 기능이 추가 되기 때문에 단정적으로 말하기가 매우 힘들다. 그래서 “volatile이 있더라도 (일반적으로) CPU 캐시를..” 라는 문장처럼 “(일반적으로)”라는 작은 구멍을 만들었다. 나도 언제든지 틀릴 수 있기 때문이다.
1) x86에서 메모리(다시 한번, 여기서 말하는 메모리는 L1/L2/L3 및 주 메모리를 통칭하는 메모리 계층을 뜻) 읽기(load)와 쓰기(store) 명령은 CPU가 알아서 처리하므로 캐시를 지나치게 할 수는 없다. 그러나 SSE2 확장부터는 쓰기(store)에 대해서 캐시 오염(cache pollution)을 최소화 하는 명령을 지원한다. 그러니까 데이터를 쓸 때 캐시를 건들이지 않고 바로 메모리에 쓰는 것이다. 이런 메모리 명령을 non-temporal이라 부른다. Non-temporal 쓰기에는 MOVNTI, MOVNTDQ, MOVNTPD가 있다. 이게 왜 멀티미디어 확장 같은 SSE 명령에 있냐면, 이런 non-temporal 쓰기는 그래픽 비디오 메모리에 효과적으로 대역폭을 높일 수 있다.
반면, non-temporal 읽기는 SSE4.1에 와서 하나 추가 되었다. 바로 streaming read를 목적으로 하는 MOVNTDQA라는 명령어다. 이 명령은 캐시 가능하지 않은 메모리 영역에 대해 (또 weakly ordering을 지원하는 영역, 이 부분은 설명하고 싶지만 너무 글이 길어져 생략) 높은 대역폭으로, 캐시를 건들이지 않고, 데이터를 바로 레지스터로 가져오게 한다. 차차세대 인텔 CPU에는 이런 non-temporal 읽기가 더욱 강화된다고 한다.
2009년 7월 17일 금요일
[mfc] WM_CLOSE, WM_DESTROY, WM_QUIT의 차이점
WM_CLOSE
윈도우가 닫히기 전에 이 메시지가 전달되며 메인 윈도우인 경우는 응용 프로그램이 종료된다는 신호이다.
이 메시지를 처리하지 않고 DefWindowProc으로 보내면 DestroyWindow 함수를 호출하여 윈도우를 파괴하도록 한다.
이 메시지가 전달되었을 때는 아직 윈도우가 파괴된 것이 아니므로 윈도우가 파괴되는 것을 중간에 차단할 수 있다.
미저장 파일이 있거나 프로그램을 종료할 상황이 되지 않을 때 사용자에게 메시지 박스를 통해 종료 사실을 확인시킬
수 있으며 이 메시지를 가로채서 단순히 return하면 DestroyWindow가 호출되지 않도록 할 수 있다.
예를 들어 프로그램을 사용자가 닫을 때 "저장할까요?"라는 확인이 필요한 경우 등에 사용할 수 있다.
WM_DESTROY
윈도우가 파괴될 때 이 메시지가 전달된다.
사용자가 Alt+F4 또는 닫기 버튼을 누를 경우 WM_CLOSE 메시지가 전달되며 이 메시지를 별도로 처리하지 않으면
DefWindowProc은 DestroyWindow 함수를 호출하여 윈도우를 파괴한다. 또는 프로그램 코드 내부에서 명시적으로
DestroyWindow 함수를 호출할 때도 윈도우가 파괴되는데 이 함수 호출 결과로 WM_DESTROY 메시지가 전달된다.
이 메시지를 받은 윈도우는 윈도우의 종료를 위한 처리를 해야 하는데 예를 들어 열어 놓은 파일을 닫고 할당한
메모리를 해제하는 등의 정리 작업을 한다. WM_CREATE에서의 초기화 처리의 반대 동작이 이 메시지에 작성되는
것이 일반적이며 그외 레지스트리에 미보관 정보를 저장하는 등의 작업을 할 수 있다. 만약 파괴되는 윈도우가
클립보드 체인에 속해 있으면 자신을 클립보드 체인에서 제거해야 한다.
DestroyWindow 함수는 파괴할 윈도우를 화면에서 숨긴 후 이 메시지를 보내므로 이 메시지를 받은 시점에서는
윈도우 자체가 파괴되지 않은 상태이다. 또한 DestroyWindow 함수는 자식 윈도우에게도 이 메시지를 차례대로
보내주는데 부모 윈도우가 먼저 이 메시지를 받고 자식 윈도우에게로 이 메시지가 보내진다. 따라서 부모 윈도우가
이 메시지를 처리하는 동안은 모든 자식 윈도우가 아직 파괴되기 전이므로 자식 윈도우를 프로그래밍할 수 있다.
파괴되는 윈도우가 메인 윈도우일 경우 PostQuitMessage 함수를 반드시 호출하여 프로세스의 메시지 루프를
종료하도록 해야 한다. 만약 이 처리를 생략하면 윈도우만 파괴되고 메시지 루프는 계속 실행중인 상태가 되므로
프로세스가 종료되지 않는다.
WM_QUIT
응용 프로그램을 종료하라는 신호이다. PostQuitMessage 함수 호출에 의해 발생하며 GetMessage 함수가 0을
리턴하도록 함으로써 메시지 루프를 종료시키는 역할을 한다. GetMessage 함수는 WM_QUIT 이외의 모든 메시지에
대해 0이 아닌 값을 리턴하므로 계속 루프를 돌지만 WM_QUIT에 대해서만 0을 리턴한다.
그래서 메시지 루프는 통상 다음과 같이 작성된다.
while(GetMessage(&Message, 0, 0, 0))
{
TranslateMessage(&Message);
DispatchMessage(&Message);
}
return (int)Message.wParam;
GetMessage 함수가 0이 아닌 값을 리턴하는 동안 무한히 이 루프를 도는데 단 WM_QUIT가 전달될 때는
while문이 종료되며 따라서 WinMain이 종료된다. 메인 윈도우의 WM_DESTROY에서는 반드시 PostQuitMessage
함수를 호출하여 메시지 루프가 종료될 수 있도록 해 주어야 한다. 그렇지 않으면 메인 윈도우는 파괴되었으나
프로세스는 계속 실행중인 상태가 된다.
PeekMessage 함수는 WM_QUIT 메시지와 상관없이 메시지 큐에 메시지가 있는지만 리턴하므로 메시지 루프를
구성할 때 따로 WM_QUIT 메시지를 점검해야 한다.
for(;;)
{
if(PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
{
if (Message.message == WM_QUIT)
{
break;
}
TranslateMessage(&Message);
DispatchMessage(&Message);
}
else
{
// 백그라운드 작업
}
}
조사한 메시지가 WM_QUIT이면 메시지 루프를 탈출하는 별도의 코드가 필요하다.
WM_QUIT는 윈도우에게 전달되는 메시지가 아니므로 윈도우 프로시저는 이 메시지를 받을 수 없다.
윈도우 프로시저까지 전달되기 전에 메시지 루프에서 이 메시지를 차단하여 루프를 탈출하게 된다.
WM_NCDESTROY
비작업 영역이 파괴될 때 보내진다.
윈도우와 그 차일드들이 먼저 파괴된 후에 비작업 영역이 파괴되므로 이 메시지는 윈도우가 가장 마지막으로 받는 메시지이다.
WM_DESTROY보다 뒤에 발생되며 이 메시지를 받았을 때는 모든 차일드가 이미 파괴된 후이다.
반면 WM_DESTROY 메시지는 차일드가 아직 파괴되기 전이다.
종료 처리가 필요할 경우는 일반적으로 WM_DESTROY 메시지에 코드를 작성하므로 이 메시지는 실용적인 가치가 거의 없는
셈이며 처리하는 경우가 극히 드물다.
윈도우가 닫히기 전에 이 메시지가 전달되며 메인 윈도우인 경우는 응용 프로그램이 종료된다는 신호이다.
이 메시지를 처리하지 않고 DefWindowProc으로 보내면 DestroyWindow 함수를 호출하여 윈도우를 파괴하도록 한다.
이 메시지가 전달되었을 때는 아직 윈도우가 파괴된 것이 아니므로 윈도우가 파괴되는 것을 중간에 차단할 수 있다.
미저장 파일이 있거나 프로그램을 종료할 상황이 되지 않을 때 사용자에게 메시지 박스를 통해 종료 사실을 확인시킬
수 있으며 이 메시지를 가로채서 단순히 return하면 DestroyWindow가 호출되지 않도록 할 수 있다.
예를 들어 프로그램을 사용자가 닫을 때 "저장할까요?"라는 확인이 필요한 경우 등에 사용할 수 있다.
WM_DESTROY
윈도우가 파괴될 때 이 메시지가 전달된다.
사용자가 Alt+F4 또는 닫기 버튼을 누를 경우 WM_CLOSE 메시지가 전달되며 이 메시지를 별도로 처리하지 않으면
DefWindowProc은 DestroyWindow 함수를 호출하여 윈도우를 파괴한다. 또는 프로그램 코드 내부에서 명시적으로
DestroyWindow 함수를 호출할 때도 윈도우가 파괴되는데 이 함수 호출 결과로 WM_DESTROY 메시지가 전달된다.
이 메시지를 받은 윈도우는 윈도우의 종료를 위한 처리를 해야 하는데 예를 들어 열어 놓은 파일을 닫고 할당한
메모리를 해제하는 등의 정리 작업을 한다. WM_CREATE에서의 초기화 처리의 반대 동작이 이 메시지에 작성되는
것이 일반적이며 그외 레지스트리에 미보관 정보를 저장하는 등의 작업을 할 수 있다. 만약 파괴되는 윈도우가
클립보드 체인에 속해 있으면 자신을 클립보드 체인에서 제거해야 한다.
DestroyWindow 함수는 파괴할 윈도우를 화면에서 숨긴 후 이 메시지를 보내므로 이 메시지를 받은 시점에서는
윈도우 자체가 파괴되지 않은 상태이다. 또한 DestroyWindow 함수는 자식 윈도우에게도 이 메시지를 차례대로
보내주는데 부모 윈도우가 먼저 이 메시지를 받고 자식 윈도우에게로 이 메시지가 보내진다. 따라서 부모 윈도우가
이 메시지를 처리하는 동안은 모든 자식 윈도우가 아직 파괴되기 전이므로 자식 윈도우를 프로그래밍할 수 있다.
파괴되는 윈도우가 메인 윈도우일 경우 PostQuitMessage 함수를 반드시 호출하여 프로세스의 메시지 루프를
종료하도록 해야 한다. 만약 이 처리를 생략하면 윈도우만 파괴되고 메시지 루프는 계속 실행중인 상태가 되므로
프로세스가 종료되지 않는다.
WM_QUIT
응용 프로그램을 종료하라는 신호이다. PostQuitMessage 함수 호출에 의해 발생하며 GetMessage 함수가 0을
리턴하도록 함으로써 메시지 루프를 종료시키는 역할을 한다. GetMessage 함수는 WM_QUIT 이외의 모든 메시지에
대해 0이 아닌 값을 리턴하므로 계속 루프를 돌지만 WM_QUIT에 대해서만 0을 리턴한다.
그래서 메시지 루프는 통상 다음과 같이 작성된다.
while(GetMessage(&Message, 0, 0, 0))
{
TranslateMessage(&Message);
DispatchMessage(&Message);
}
return (int)Message.wParam;
GetMessage 함수가 0이 아닌 값을 리턴하는 동안 무한히 이 루프를 도는데 단 WM_QUIT가 전달될 때는
while문이 종료되며 따라서 WinMain이 종료된다. 메인 윈도우의 WM_DESTROY에서는 반드시 PostQuitMessage
함수를 호출하여 메시지 루프가 종료될 수 있도록 해 주어야 한다. 그렇지 않으면 메인 윈도우는 파괴되었으나
프로세스는 계속 실행중인 상태가 된다.
PeekMessage 함수는 WM_QUIT 메시지와 상관없이 메시지 큐에 메시지가 있는지만 리턴하므로 메시지 루프를
구성할 때 따로 WM_QUIT 메시지를 점검해야 한다.
for(;;)
{
if(PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
{
if (Message.message == WM_QUIT)
{
break;
}
TranslateMessage(&Message);
DispatchMessage(&Message);
}
else
{
// 백그라운드 작업
}
}
조사한 메시지가 WM_QUIT이면 메시지 루프를 탈출하는 별도의 코드가 필요하다.
WM_QUIT는 윈도우에게 전달되는 메시지가 아니므로 윈도우 프로시저는 이 메시지를 받을 수 없다.
윈도우 프로시저까지 전달되기 전에 메시지 루프에서 이 메시지를 차단하여 루프를 탈출하게 된다.
WM_NCDESTROY
비작업 영역이 파괴될 때 보내진다.
윈도우와 그 차일드들이 먼저 파괴된 후에 비작업 영역이 파괴되므로 이 메시지는 윈도우가 가장 마지막으로 받는 메시지이다.
WM_DESTROY보다 뒤에 발생되며 이 메시지를 받았을 때는 모든 차일드가 이미 파괴된 후이다.
반면 WM_DESTROY 메시지는 차일드가 아직 파괴되기 전이다.
종료 처리가 필요할 경우는 일반적으로 WM_DESTROY 메시지에 코드를 작성하므로 이 메시지는 실용적인 가치가 거의 없는
셈이며 처리하는 경우가 극히 드물다.
2009년 7월 10일 금요일
[socket] IOCP에서 합리적인 강제종료 방법
IOCP의 기본 규칙은, 하나의 세션에 대해 IO요청중인 상태에서는 이게 완료통보로 오기까지는
이 세션에 대해 다른 요청작업을 하면 안된다는 것이다
그렇다면 PostQueuedCompletionStatus(hcp, cbTransferred, ssid, p); 는 방법으로 적절하지 못하다
하나의 세션은 IO요청중이거나, 완료통보 된 상태에서 작업하여 다시 IO요청으로 가기전의 상태만 존재하기 때문이다
즉, 완료통보 상태가 되려면 반드시 client 측에서 뭔가 신호 하나를 던져줘야만 한다는 것이다
P.. 방법을 사용할 경우, IO요청중에 이루어진다면, 맨위에 기술한 기본 규칙에 어긋나므로 부적절
다음으로 작업중에 요청이 된다면, 하나의 세션에 대해 2개의 쓰레드에서 작업을 해버리므로 부적절(P.. 는 직접적 큐잉방법이므로 호출 즉시, G.. 로 뜨게됨)
그렇다면 방법은 두가지다... (효율적인 방법을 꼽는 다면)
1. 쓰레드에 안전한 ConnList 를 사용하는 방법
해당 struct에 bool disconn을 추가하여, 매 GQCS 호출시, 체크를 하여 true 일때는 transferred byte 를 0으로 바꿔서
자연스럽게 클라이언트의 종료 루틴으로 빠지게 하는 방법
장점: P... 보다는 루틴의 안정성이 뛰어남
단점: 클라이언트가 반드시 길이 有의 패킷을 보내야한다는점, 마지막에 완료 루틴으로 온 패킷이 drop(씹히는 상태)된다는점
2. closesocket을 사용하는 방법
하지만 이때 또 주의사항이 있다
1. c.. => IO요청 시작(중) 일때 : WSARecv 에러리턴즉시 처리가능
2. IO요청 시작(중) 일때 => c.. : 정상적인 완료통보로 뜸
3. c.. => 세션 작업중일때 : 다시 IO요청으로 가므로 : 1과 동일
4. c.. => 세션 종료 작업 중일때 : closesocket이 중복허용하지 않는다면 에러가 발생할 수도??? 아니라면 문제없음(IO요청이 더이상 안일어나므로)
5. c.. => 세션 종료 작업 이후에 요청될때 : 뭐, 없는 개체이므로 JUST OK (하지만 없는 개체에 대해 위의 함수를 허용하지 않는다면, 에러, 아니라면 문제 없음)
=> 확인 결과: 문제 없음
장점:
- 이미 워커쓰레드에서 close 루틴이 진행중이라 하더라도 delete ptr 및 push, ConnList erase 과정은 여기서만 담당하므로 상관없음
- 동일 socket에 대해 두번씩이나 closesocket를 호출한다하더라도 에러는 안걸림
- 워커쓰레드 함수상에서는 항상 IO요청이 이루어지도록 하고있으므로 아래 두가지 항목의 장점이 있음
- 세션에 대해 작업중일때는 WSARecv 호출 즉시 종료처리를 해주면 됨
- IO요청 중일때에는 자연스럽게 완료통보상으로 클라이언트의 FIN 핸드쉐이크의 결과 신호가 리턴됨
단점: 없음
주의사항: 이 방법을 사용하므로써, 어떤 에러가 일어나게 될지는 아직 검증되지 않은 상태임
- 이상이 없을 것이다: closesocket(sock) 에서 어짜피 sock은 전역변수가 아니니 상관은 없음
(IOCP 에서는 이미 어셉트 쓰레드와 워커 쓰레드가 나뉘어지므로, 하나의 유저의 accept와 동시에 다른 유저의 closesocket이 처리될 수 있음, 그렇다면, 딱히 이 부분은 동기화에 너무 과하게 신경쓰지 않아도 되는 부분일듯)
[출처] IOCP에서 합리적인 강제종료 방법|작성자 Harpy
이 세션에 대해 다른 요청작업을 하면 안된다는 것이다
그렇다면 PostQueuedCompletionStatus(hcp, cbTransferred, ssid, p); 는 방법으로 적절하지 못하다
하나의 세션은 IO요청중이거나, 완료통보 된 상태에서 작업하여 다시 IO요청으로 가기전의 상태만 존재하기 때문이다
즉, 완료통보 상태가 되려면 반드시 client 측에서 뭔가 신호 하나를 던져줘야만 한다는 것이다
P.. 방법을 사용할 경우, IO요청중에 이루어진다면, 맨위에 기술한 기본 규칙에 어긋나므로 부적절
다음으로 작업중에 요청이 된다면, 하나의 세션에 대해 2개의 쓰레드에서 작업을 해버리므로 부적절(P.. 는 직접적 큐잉방법이므로 호출 즉시, G.. 로 뜨게됨)
그렇다면 방법은 두가지다... (효율적인 방법을 꼽는 다면)
1. 쓰레드에 안전한 ConnList 를 사용하는 방법
해당 struct에 bool disconn을 추가하여, 매 GQCS 호출시, 체크를 하여 true 일때는 transferred byte 를 0으로 바꿔서
자연스럽게 클라이언트의 종료 루틴으로 빠지게 하는 방법
장점: P... 보다는 루틴의 안정성이 뛰어남
단점: 클라이언트가 반드시 길이 有의 패킷을 보내야한다는점, 마지막에 완료 루틴으로 온 패킷이 drop(씹히는 상태)된다는점
2. closesocket을 사용하는 방법
하지만 이때 또 주의사항이 있다
1. c.. => IO요청 시작(중) 일때 : WSARecv 에러리턴즉시 처리가능
2. IO요청 시작(중) 일때 => c.. : 정상적인 완료통보로 뜸
3. c.. => 세션 작업중일때 : 다시 IO요청으로 가므로 : 1과 동일
4. c.. => 세션 종료 작업 중일때 : closesocket이 중복허용하지 않는다면 에러가 발생할 수도??? 아니라면 문제없음(IO요청이 더이상 안일어나므로)
5. c.. => 세션 종료 작업 이후에 요청될때 : 뭐, 없는 개체이므로 JUST OK (하지만 없는 개체에 대해 위의 함수를 허용하지 않는다면, 에러, 아니라면 문제 없음)
=> 확인 결과: 문제 없음
장점:
- 이미 워커쓰레드에서 close 루틴이 진행중이라 하더라도 delete ptr 및 push, ConnList erase 과정은 여기서만 담당하므로 상관없음
- 동일 socket에 대해 두번씩이나 closesocket를 호출한다하더라도 에러는 안걸림
- 워커쓰레드 함수상에서는 항상 IO요청이 이루어지도록 하고있으므로 아래 두가지 항목의 장점이 있음
- 세션에 대해 작업중일때는 WSARecv 호출 즉시 종료처리를 해주면 됨
- IO요청 중일때에는 자연스럽게 완료통보상으로 클라이언트의 FIN 핸드쉐이크의 결과 신호가 리턴됨
단점: 없음
주의사항: 이 방법을 사용하므로써, 어떤 에러가 일어나게 될지는 아직 검증되지 않은 상태임
- 이상이 없을 것이다: closesocket(sock) 에서 어짜피 sock은 전역변수가 아니니 상관은 없음
(IOCP 에서는 이미 어셉트 쓰레드와 워커 쓰레드가 나뉘어지므로, 하나의 유저의 accept와 동시에 다른 유저의 closesocket이 처리될 수 있음, 그렇다면, 딱히 이 부분은 동기화에 너무 과하게 신경쓰지 않아도 되는 부분일듯)
[출처] IOCP에서 합리적인 강제종료 방법|작성자 Harpy
[socket] IOCP 서버 제작에 있어서의 유의할 점들 2
서버 프로그래밍을 하다 보면 정말 웃지 못할 사건들을 많이 접하게 된다. 클라이언트 접속이 1000개까지는 문제가 없다가 1001번째에 갑자기 크래시(crash)가 발생하기도 하고, 서버를 구동시킨 지 2~3일이 지난 시점부터 조금씩 리소스가 새기도 한다. 부지런한 개발자들은 MSDN의 버그 리포트와 테크니컬 기사를 샅샅이 뒤져보며 운영체제 또는 하드웨어의 버그이길 간절히 바란다.
그러나 아쉽게도 이런 경우의 대부분이 본인의 실수에서 비롯되므로 유용한 단서를 찾기는 힘들다. 몇몇 뛰어난 프로그래머들은 몇 날 며칠을 밤새워 고생하다 갑자기 무언가 드디어 알아챘다는 듯 멀쩡히 죄 없는 PC를 포맷하기도 한다(필자도 과거에 일말의 희망을 걸고 수차례 포맷해 본 경험이 있다. 물론 다시 설치한다고 해결될 성질의 것이 결코 아니었다). 앞으로의 설명은 서버의 성능이나 최적화 문제보다는 안정적이고 유연한 네트워킹 환경을 구축하는 것에 초점을 맞춰 진행할 것이다.
소켓 API 리뷰
네트워크 개발자들이 겪는 문제들의 많은 부분이 소켓과 TCP의 특성을 제대로 이해하지 못한 것에서 시작한다. 다소 지루할 수도 있겠지만, 먼저 각 소켓 API의 중요한 점을 되짚어 보는 것으로 시작하겠다.
소켓을 생성하는 함수는 두 가지가 있다. <리스트 1>처럼 WSA Socket 쪽이 좀더 다양한 프로토콜을 열거할 수 있다. 하지만 우리가 만들 서버는 인터넷 TCP 프로토콜만을 사용하기 때문에 어느 것을 사용해도 문제없다. 단 WSASocket으로 소켓을 생성하는 경우 dwFlags 파라미터에 WSA_FLAG_OVERLAPPED를 넘겨 오버랩드 속성을 가지도록 해야 한다. 그렇지 않으면 이후 다룰 WSASend, WSARecv 등의 오버랩드 호출은 무시될 것이다. 오버랩드 I/O에 관한 내용은 추후 자세히 설명하겠다.
int bind(
SOCKET s,
const struct sockaddr FAR *name,
int namelen
);
빈 소켓만으로는 아무 것도 할 수 없다. 소켓과 로컬 주소를 연결시킨 뒤에야 비로소 네트워크 통신을 할 수 있는데, bind가 이러한 역할을 해 준다. 먼저 name 파라미터를 자세히 살펴보자(<리스트 2>).
sockaddr은 bind할 주소를 지정하는데 쓰이는 16바이트 크기의 구조체다. 소켓에는 다양한 주소 패밀리(AF_UNIX, AF_INET, AF_IMPLINK, ...)와 각각의 하위 프로토콜이 존재한다. 각 주소 패밀리에 따라 주소 지정 방법이 다를 수 있는데, 우리는 인터넷 프로토콜(AF_INET)을 사용하므로 AF_INET의 주소 지정을 쉽게 하기 위해 우측의 sockaddr_in을 사용한다.
보통 sockaddr_in의 sin_addr 필드에 ADDR_ANY를 집어넣는데, 이것은 멀티홈드 호스트(예 : 여러 LAN 카드가 꽂혀 있는 호스트)의 특정 네트워크 주소를 선택하지 않겠다는 뜻이다. 그러나 성능이나 보안 측면을 강화시키기 위해 특정 네트워크의 주소를 입력할 수 있다.
윈속이 제공하는 Name Resolution 함수 중 하나인 gethostb yname를 사용해 로컬 호스트의 네트워크를 열거할 수 있다.
HOSTENT *he = gethostbyname( host_name );
he->h_addr_list[0]; // 첫 번째(예 : LAN CARD #1)
he->h_addr_list[1]; // 두 번째(예 : LAN CARD #2)
he->h_addr_list[2]; // 세 번째(예 : CABLE MODEM)
다음으로 sockaddr_in의 포트를 살펴보자. 이 값이 0이면 시스템은 적당한 포트를 찾아 맵핑해 준다. 윈도우 2000에서의 기본 값은 1024~5000 사이의 값인데, 부족할 경우 TCP/IP 관련 레지스트리 키(MaxUserPort)의 최대 값을 변경할 수 있다.
MaxUserPort
Key: HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServicesTcpipParameters
Value Type: REG_DWORDmaximum port number
Valid Range: 500065534 (decimal)
Default: 0x1388 (5000 decimal)
이외에도 MaxFreeTcbs와 MaxHashTableSize 등을 조절해 맵핑될 소켓 수를 조절할 수 있다. 관심있는 독자는 「Microsoft Win dows 2000 TCP/IP Implementation Details」를 참고하기 바란다.
그리고 당연하겠지만 같은 주소로의 두 번째 bind는 실패한다. 간혹, 이전에 bind된 소켓을 분명히 닫았음에도 두 번째 bind가 실패하는 경우가 있는데, 이것은 이전 소켓이 실제로 완전히 닫히지 않고 TIME_WAIT 상태에 머물러 있기 때문이다. 서버를 재시작하는 경우에도 발생할 수 있는데, 이런 상황을 피하려면 setsockopt 함수를 사용해 SO_REUSEADDR 옵션을 셋팅하면 된다. TCP 상태에 대해 잘 모르고 있다면 TCP/IP 서적 등을 참고해 반드시 숙지하기 바란다.
int connect(
SOCKET s,
const struct sockaddr FAR *name,
int namelen
);
상대방 호스트에 접속하기 위해 connect를 호출한다. connect를 호출하기 위해 bind할 필요는 없다. 소켓을 생성한 뒤 바로 connect를 호출하면 자동으로 시스템이 지정하는 포트로 bind되는데, 이 역시 1024~5000 사이의 값을 가진다. 스트레스 테스트(stress test)를 하면 이 정도의 연결이 부족할 수 있다. 따라서 필요하다면 앞서 언급한대로 레지스트리 값을 적당한 값으로 설정해 주자.
int listen(
SOCKET s,
int backlog
);
listen은 소켓에 TCP 접속을 받아들일 수 있는 속성을 부여해 준다. backlog 파라미터는 동시에 접속이 몰렸을 때를 처리하기 위한 큐의 크기인데, 보통 시스템의 최대 값을 지정해서 쓴다. 윈속 2.0 이전 버전에서 이 값의 최대 값은 5였는데, 이것은 접속 요청이 최대 5개까지 큐될 수 있다는 것을 뜻한다. 윈속 2가 등장하면서 SO MAXCONN이라는 상수 값을 사용하는데, 내부적으로 윈도우 2000 서버는 200개, 프로는 5개까지 설정된다. 접속 처리를 위해 accept를 호출하면, backlog 큐의 첫 번째 노드가 삭제되면서 다른 접속 요청을 큐에 넣을 수 있다. backlog 큐가 가득 차면 클라이언트의 connect 호출은 WSAECONNREFUSED 에러를 리턴한다.
SOCKET accept(
SOCKET s,
struct sockaddr FAR *addr,
int FAR *addrlen
);
accept는 서버 소켓의 접속 큐에서 첫 번째 노드를 가져와 소켓을 생성한 뒤 리턴한다. 리턴된 소켓은 s 파라미터와 동일한 속성을 가진다는 것을 기억해 두자.
TCP 패킷을 주고받을 때 사용한다. 가장 빈번하게 호출되는 함수인 만큼 네트워크 개발자들이 주의해야 하는 부분이다. TCP는 신뢰할 수 있는(reliable) 스트림 기반의 프로토콜이다. 여기서 스트림 기반이라는 것에 주목할 필요가 있다. 수신자는 언제나 송신자가 전송한 만큼 받기 마련이지만, 이것이 곧 send, recv 함수의 호출 횟수까지 같다는 것을 뜻하지는 않는다.
전송된 패킷은 인터넷의 수많은 게이트웨이를 경유하면서 상대방에게 도착하는데, send 호출과는 관계없이 패킷이 뭉쳐오기도 하고 완전히 조각난 상태로 도착하기도 한다. TCP는 보낸 순서대로 끝까지 도착하는 것을 보장하는 것이지, 전송 횟수까지 보장하는 것은 아니다. 그야말로 스트리밍 송수신이다. 따라서 반드시 송수신자 간에 패킷의 완료 여부를 알 수 있도록 사인을 해 두어야 한다. 보통 패킷의 앞이나 뒤에 이를 확인할 수 있도록 구조를 잡는다. 다음의 의사 코드(pseudo code)는 일반적으로 소켓 수신을 처리하는 방법을 보여준다.
// TCP 수신 처리 방법
ret = recv( s, buf, sizeof( buf ), 0 );
if ( ret <= 0 ) // ret 에러 처리
// 패킷이 잘려 올 수 있기 때문에 이전 패킷과 합친다.
queue.add( buf, ret );
// 패킷이 뭉쳐 올 수도 있으므로 완료 패킷이 없어질 때까지 반복한다.
while ( queue.has_completion_packet() ){
process_completion_packet( queue.get_completion_packet() );
// 처리한 패킷은 큐에서 삭제한다.
queue.remove_completion_packet();}
또 다른 주의해야 할 점은 send, recv 함수의 리턴 값을 명확히 처리해 두는 것이다. 넌블러킹 소켓에서 send 호출은 우리가 생성한 버퍼(스택 또는 힙)를 커널 버퍼(소켓 버퍼)로 복사하고 커널 버퍼에 복사된 크기를 리턴한다. 이 때 커널 버퍼의 공간이 부족하여 요청한 크기와 리턴된 크기가 다를 수 있는데, 이런 경우 보통 네트워크 지연으로 판단해 접속을 끊거나 사용자가 만든 송신 큐에 임시로 보관해 두고 다음 송신이 가능해졌을 때 재전송하는 방법으로 해결한다. recv 함수는 보통 수신된 패킷 크기를 리턴하며, 0을 리턴하는 경우 정상적으로 접속이 종료되었다는 것으로 볼 수 있다. 단, 서버가 강제로 접속을 끊는 경우 recv는 SOCKET_ERROR를 리턴하면서 GetLastError() 함수로 WSAECONNRESET와 같은 에러 코드를 얻을 수 있다. 에러를 처리해 두면 send, recv 함수가 왜 실패했는지 명확해지기 때문에, 이후 네트워크 에러가 발생했을 때 어떻게 대처해야 할 것인가는 어렵지 않게 판단할 수 있다.
int shutdown(
SOCKET s,
int how
);
int closesocket(
SOCKET s
);
서버 프로그래밍을 할 때 주의해야 할 부분 중 하나가 소켓을 닫을 때의 처리이다. 안전하게 종료하기 위해서는 모든 데이터를 전송한 뒤 접속을 끊으려 할 때 shutdown 호출을 사용해 이 사실을 상대방에게 알려줘야 한다. 물론 상대방도 마찬가지다. 이러한 처리를 Graceful Closure라고 하며 <표 1>처럼 종료 처리를 한다(MSDN의 Graceful Shutdown, Linger Options, and Socket Closure 참조).
<표 1>과 같이 shutdown을 사용하면 남아 있는 데이터를 보낼 기회를 제공함으로써 소켓의 연결 종료를 제어할 수 있다. 그런데 아직 한 가지 고려해 볼 문제가 남아 있다. shutdown이나 closesocket 모두 ACK(TCP Handshake)를 확인하지 않고 리턴한다는 점이다. 그렇다면 어떻게 우리가 전송한 데이터가 정말로 보내졌는지 확인할 수 있을까?
Graceful Closure 설명을 하면서 Linger 옵션에 관한 설명을 빠뜨릴 수 없다. Linger 옵션은 closesocket 호출로 소켓이 닫히면서 남아 있는 데이터 전송을 어떻게 다룰 것인가를 설정한다. TCP는 상대방으로부터 보낸 패킷에 대한 ACK를 받아야 전송이 완료된 것으로 간주한다. <표 1>에서 서버 측을 보면 상대방으로부터 FIN 세그먼트를 확인한 뒤 데이터를 보내고 shutdown 호출 후 closesocket 호출로 마침내 소켓을 닫는다. 정상적인 과정이지만 내부적으로 FIN ACK는 물론 이전에 전송한 데이터조차 ACK를 받지 못했을 가능성이 있다. 다행히도 시스템은 기본적으로 소켓이 닫힌 후의 클로징 핸드세이크(Closing Handshake)를 처리할 시간(2MSL)을 준다.
Linger 옵션은 이 시간을 조절할 수 있게 하는데 일반적인 경우에 Linger 옵션을 설정할 필요는 없다. Linger를 설정하는 경우 블러킹 소켓에선 closesocket 호출시 블럭될 수 있고, 넌블러킹 소켓은 closesocket에서 WSAEWOULDBLOCK을 리턴하므로 완료되기까지 수차례 호출해야 한다는 단점이 있다. 간혹 이 시간을 0으로 설정하기도 하는데 이것을 하드 클로저(Hard Closure)라고 하며, 이 때 서버는 closesocket 즉시 해당 소켓에 관한 모든 리소스를 반납한다. 이 경우 상대방은 모든 데이터를 수신하지 못한 채 WSAECON NRESET 에러를 받기 때문에 특별한 경우가 아니라면 권장하지 않는다. <표 2>는 MSDN에서 발췌한 것으로 Linger 옵션에 따른 closesocket 작동 방식을 나타낸다.
오버랩드 I/O
오버랩드(overlapped) I/O란 문자 그대로 중첩된 입출력을 뜻한다. CPU에 비해 디스크나 통신 디바이스의 입출력에 걸리는 속도는 대단히 느리기 때문에 오버랩드 I/O를 사용해 디바이스 입출력시에 걸리는 시간 지연을 피할 수 있다. 물론 윈속은 이미 여러 가지 비동기 입출력 방법을 제공하고 있어, 굳이 오버랩드 I/O를 사용하지 않더라도 거의 같은 성능의 비동기 입출력을 구현할 수 있다. 잠시 후 소개할 IOCP(IO Completion Port)와 함께 사용되기 때문에 한 번쯤 거쳐야 할 관문 정도로만 생각해 두고 부담없이 진행해 나가도록 하자. send, recv 대신 WSASend, WSARecv를 사용해 오버랩드 I/O를 할 수 있다.
함수 파라미터의 구체적인 사용법은 이후에 논하기로 하고, 지금은 WSAOVERLAPPED 구조체를 사용해 함수를 호출한다는 것 정도만 알아두자. 넌블러킹 소켓에서와 마찬가지로 오버랩드를 사용한 WSASend, WSARecv 호출은 특별한 이상이 없는 한 WSAE WOULDBLOCK을 리턴한다. 오버랩드 I/O의 완료 여부를 확인하려면 다음의 함수를 호출하면 된다.
BOOL WSAGetOverlappedResult(
SOCKET s,
LPWSAOVERLAPPED lpOverlapped,
LPDWORD lpcbTransfer,
BOOL fWait,
LPDWORD lpdwFlags
);
사실은 바로 IOCP를 설명해도 되지만, 오버랩드 I/O를 설명하면서 그냥 지나치면 WSAGetOverlappedResult가 섭섭해 할까봐 한번 등장시켜 보았다. 바로 뒤에 설명할 IOCP를 사용해 완료 통보를 받게 되면, 더 이상 이 함수는 설 자리가 없어지기 때문에 독자의 기억 속에 그리 오래 머물 것 같진 않다. 오버랩드 I/O의 다양한 쓰임새나 윈속의 다른 비동기 입출력 방법에 대해 자세히 알고 싶다면, 마이크로소프트 프레스의 「Network Programming for Microsoft Windows」를 참고하기 바란다.
디바이스 입출력 완료 통보 포트, IOCP
IOCP는 디바이스의 입출력 완료를 통보하기 위한 포트로서, 빠른 입출력 통보 외에 최적화된 쓰레드 풀링 기술을 포함하고 있다. 디바이스와 IOCP를 연결하는 데 개수 제한이 없고, 최적화된 쓰레드 풀링을 통해 고성능 서버를 구축하는 데 큰 도움이 되기 때문에, 현재 많은 윈도우 서버 프로그래머들의 사랑을 받고 있는 귀여운 녀석이기도 하다. 제공되는 성능에 비해 사용법 자체는 의외로 간단해 프로그래머는 IOCP를 만들고, 적절한 수의 워커 쓰레드를 생성한 다음 입출력 완료 통보를 기다리기만 하면 된다.
HANDLE CreateIoCompletionPort (
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreads
);
IOCP를 만들어 주는 좀 웃기는(?) 함수다. 이 함수는 사실상 두 가지 역할을 하는데, 하나는 이름 그대로 IOCP를 생성하는 것이고(네 번째 파라미터만 사용), 다른 하나는 오버랩드 속성을 지닌 소켓과 IOCP를 연결하는 것이다(앞의 세 파라미터만 사용). 「Program ming Server-Side Applications for Microsoft Windows 2000」의 저자 제프리 리처(Jeffrey Richter)도 언급한 것이지만, 함수를 왜 저렇게 만들어 놨는지 도저히 이해되지 않는 부분이다. 어쨌든 꽤 중요한 함수이기에 다음의 일련의 흐름을 보면서 IOCP 체계를 확실히 이해해 둘 필요가 있다.
짾 IOCP를 만든다
HANDLE h = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, 0, 0 );
처음엔 당연히 IOCP를 만들어야 한다. 먼저 IOCP를 만들 때는 앞의 세 파라미터가 쓰이지 않으므로 가볍게 INVALI D_HANDLE_ VALUE, 0, 0을 넘겨주자. NumberOfConcurrent Threads에 오버랩드 I/O를 처리하기 위해 동시에 실행할 수 있는 쓰레드의 수를 지정하는데, 0을 넘기면 시스템은 설치된 프로세서(CPU)의 수만큼 할당한다.
짿 IOCP를 감시할 쓰레드를 생성한다
SYSTEM_INFO si
GetSystemInfo( &si );
numThreads = si.dwNumberOfProcessors * 2;
for ( i = 0; i < numThreads; i++ )
_beginthreadex( NULL, 0, WorkerThread, ... );
IOCP의 완료 통보를 받을 쓰레드를 생성한다. 좀 이상한 부분이 눈에 띄지 않는가? IOCP를 만들 때 CPU 수만큼의 쓰레드가 동시에 돌아갈 수 있도록 그 수를 제한해 놓고선, 정작 쓰레드는 그 두 배만큼 만들고 있다. 이는 워크 쓰레드가 Wait 상태에 다다를 때(예 : Sleep 호출) IOCP가 또 다른 쓰레드에 완료 통보를 해주기 때문에 여분의 쓰레드를 미리 만들어 두는 것이다. 두 배라고 한 것은 필자 맘대로 정한 수치이고 서버의 구현 방법이나 서비스 내용에 따라 적절한 값을 찾는 것이 좋다.
쨁 소켓과 IOCP를 연결시킨다
CreateIoCompletionPort( (HANDLE) my_socket, iocp_handle, completion_key, 0 )
오버랩드 I/O를 IOCP로 통보받기 위해 소켓 핸들과 IOCP 핸들을 연결시켜야 한다. 세 번째 파라미터인 completion_key는 나중에 오버랩드 I/O에 대한 완료 통보를 받을 때, 어떤 소켓으로부터의 완료 통보인지 식별할 수 있게 해주는 것으로 보통 소켓을 포함하고 있는 객체의 주소를 넘긴다. 그리고 앞서 언급했듯 마지막 파라미터는 쓰지 않는다.
쨂 IOCP를 감시(+_+)한다
WorkerThread()
{
while ( TRUE )
{
GetQueuedCompletionStatus(
iocp_handle, // HANDLE CompletionPort
&bytes_transferred, // LPDWORD lpNumberOfBytes
&completion_key, // PULONG_PTR lpCompletionKey
&overlapped, // LPOVERLAPPED *lpOverlapped
INFINITE ); // WORD dwMilliseconds
// completion_key와 오버랩드를 보면
// 어떤 소켓의 오버랩드 I/O인지 구별할 수 있다.
}
};
처음 보는 함수가 나타났다. 이미 독자도 예상하고 있겠지만 Get QueuedCompletionStatus가 IOCP의 부름(Thread Wake-Up)을 받기 위해 기다리고 있다. 이 함수를 통해 어떤 소켓의 어떤 호출인지, 또 얼마만큼 전송이 되었고 에러 코드는 무엇인지 등을 확인할 수 있다.
쨃 WSASend, WSARecv 등의 오버랩드 I/O를 시작한다
WSASend(
s, &wsabuf, 1,
&bytes_transferred, 0, &overlapped, NULL );
WSARecv(
s, &wsabuf, 1,
&bytes_transferred, &flag, &overlapped, NULL );
패킷을 주고받기 위해 오버랩드 구조체를 이용한다. WSASend, WSARecv 각각의 파라미터에 주의를 기울일 필요가 있는데, 이에 관한 자세한 설명은 다음 번에 직접 네트워크 라이브러리를 구현하면서 자세히 설명하기로 하고 지금은 IOCP 체계를 이해하는 것에 초점을 맞추자.
두 개의 CPU가 설치된 윈도우 2000에서 <그림 2>와 같은 IOCP 서버가 실행 중이라고 가정해 보자. IOCP를 만들 때 NumberOf ConcurrentThreads에 0을 넘겨 동시 쓰레드(concurrent thread)의 수가 두 개가 되도록 했다. #1은 이미 완료 통보를 받아 해당 객체의 송수신을 처리 중이고, #2가 지금 막 완료 통보를 받고 있다. 이렇게 되면 정확히 두 개의 쓰레드가 동시에 실행중인 것이며, IOCP 큐에 완료 통보가 도착하더라도 IOCP는 다른 쓰레드(#3)에 완료 통보를 하지 않는다. 이 시점에서 발생할 수 있는 두 가지 시나리오를 세워 보았다.
◆ 시나리오 1 - #1이 완료 통보 처리를 마침
완료 처리가 끝났기 때문에, #1은 다시 GetQueuedCompletion Status 함수를 호출한다. 이때 IOCP는 큐에 쌓여 있던 다른 완료 통보를 다시 #1에 넘겨준다. 먼저 기다리고 있던 #3에 넘기지 않는 이유는 쓰레드 컨텍스트 스위칭을 줄이기 위해서다.
◆ 시나리오 2 - #1이 처리 도중 Sleep을 호출
프로그래머가 무슨 생각으로 Sleep을 호출했는지는 모르겠지만 어쨌든 쓰레드 Wait 상태에 돌입한다. 이 때 기다리고 있던 #3이 IOCP로부터 완료 통보를 받는다. 이 시점의 실제 동시 쓰레드 수는 2+1(Wait State)이며, #1이 잠에서 깨어날 경우 순간적으로 IOCP를 만들 때 지정했던 쓰레드 수의 범위를 초과할 수 있다. 이후 IOCP는 다시 동시에 실행될 쓰레드 수가 2가 되도록 조절한다. 이러한 이유로 IOCP 생성시에 지정해 준 NumberOfConcurrentThreads의 수보다 실제로 많은 워커 쓰레드를 생성하는 것이다.
IOCP를 이용한 서버 구현시 주의사항
많은 개발자들이 범하는 대부분의 실수는 멀티 쓰레드와 비동기 입출력의 이해 부족에 기인한다. 멀티 쓰레드 프로그래밍만 하더라도 어렵고 복잡한데, 여기에 비동기 입출력까지 더해지니 네트워크 개발자들이 겪을 그 혼란은 충분히 짐작할 만하다. 이번엔 IOCP를 이용해 서버 네트워크 코드를 구현할 때 특히 주의해야 점을 알아보기로 하자.
에러 코드를 반드시 확인한다
WSASend, WSARecv 등을 통해 오버랩드 I/O를 할 때 정상적인 경우 WSAEWOULDBLOCK을 리턴한다. 그러나 원격 호스트가 접속을 끊거나(WSAECONNRESET), 가상 회선에 문제가 발생했을 때(WSAECONNABORTED)와 같은 문제는 빈번히 발생한다. 이 경우 별 수 없이 이쪽에서도 접속을 끊는 수밖에 없다. 골치 아픈 부분은 WSAENOBUFS와 같은 에러를 만나는 경우다. 다음 호에서 구현을 통해 자세히 알아보겠지만, 시스템 리소스(커널 리소스) 제한에 걸리게 되면 오버랩드 I/O는 ‘WSAENOBUFS 에러’를 내뱉으며 실패한다. 마찬가지로 ‘그냥 접속을 끊으면 되는 것 아니냐?’고 반문하겠지만, 그것이 클라이언트가 아니라 대량의 클라이언트가 접속한 상황에서의 서버간 송수신에서 발생하는 것이라면 더욱 심각해진다. 대량의 클라이언트가 접속한 상황에서는 언제든지 시스템 리소스가 바닥날 수 있기 때문에 클라이언트의 연결을 적절히 분산시킬 수 있는 메커니즘이 필요하며, 불가피한 경우 클라이언트의 접속을 제한해야 한다.
참조 카운트를 유지한다
오버랩드 호출을 걸어두고, 완료 통보를 받기도 전에 오버랩드 버퍼나 소켓 객체가 삭제돼서는 안된다. 또한 한 객체에 대해 둘 이상의 오버랩드 호출이 있는 경우엔 반드시 참조 카운트를 유지해야 하며, 객체를 제거해야 하는 경우에 이 참조 카운트가 0인지 확인해야 한다. 참조 카운트를 유지하지 않고 완료 통보가 아직 더 남아있는 상태에서 객체를 삭제하면, 당연한 것이지만 그 다음 완료 통보시 엉뚱한 메모리 위치를(IOCP로 말하자면 CompletionKey나 Overlapped Pointer) 가리켜 크래시를 발생시킨다. 원인을 모르고 객체가 삭제된 것에 분개해 정적 메모리 관리 등으로 당장 급한 불을 끄는 것은 근본적인 해결책이 될 수 없다.
데드락을 주의한다
IOCP의 워커 쓰레드만을 이용해 서비스 코드를 구현할 때 주의해야 할 사항이 있다. 주로 샘플 소스로 쓰이는 에코(echo) 서버나, 실제로 IOCP로 구현되어 있는 IIS(Internet Information Server)와 같은 서버는 객체간 상호 참조가 발생되지 않아 이러한 문제는 없다. 그러나 채팅 서버와 같은 상호 참조(즉, 한 객체가 다른 객체에 직접적인 접근이 일어나는 것)가 발생하는 서비스에서는 양방향 상호 참조가 동시에 일어나는 경우에 데드락(dead-lock)이 발생할 수 있다. 따라서 동기화에 각고의 노력을 기울여야 하며, 이것보다는 패킷을 처리하는 전용 쓰레드를 따로 두어 일괄적으로 처리하는 방법을 권한다.
다양한 의견 기다리며
이번 호에서는 본격적인 구현에 앞서 필요한 내용들을 쭉 살펴봤다. 지면 관계상 조금 빠르게 진행된 감이 있는데 부족한 부분은 참고자료를 살펴보기 바란다. 필자도 부족한 부분이 많기 때문에 오해하고 있는 부분이 있거나, 잘못된 코드를 제공할 수 있다. 이런 부분이 발견되면 즉시 연락해 바로잡을 수 있도록 도와주길 바란다. 그리고 이번 기사에 대한 질책이나 조언, 다양한 의견을 접할 수 있다면 앞으로 좋은 기사를 쓰는 데 큰 도움이 될 것이다. 부담없이 연락해 주길..(^^;)
[출처] 윈속 프로그래밍 리뷰|작성자 poins
그러나 아쉽게도 이런 경우의 대부분이 본인의 실수에서 비롯되므로 유용한 단서를 찾기는 힘들다. 몇몇 뛰어난 프로그래머들은 몇 날 며칠을 밤새워 고생하다 갑자기 무언가 드디어 알아챘다는 듯 멀쩡히 죄 없는 PC를 포맷하기도 한다(필자도 과거에 일말의 희망을 걸고 수차례 포맷해 본 경험이 있다. 물론 다시 설치한다고 해결될 성질의 것이 결코 아니었다). 앞으로의 설명은 서버의 성능이나 최적화 문제보다는 안정적이고 유연한 네트워킹 환경을 구축하는 것에 초점을 맞춰 진행할 것이다.
소켓 API 리뷰
네트워크 개발자들이 겪는 문제들의 많은 부분이 소켓과 TCP의 특성을 제대로 이해하지 못한 것에서 시작한다. 다소 지루할 수도 있겠지만, 먼저 각 소켓 API의 중요한 점을 되짚어 보는 것으로 시작하겠다.
소켓을 생성하는 함수는 두 가지가 있다. <리스트 1>처럼 WSA Socket 쪽이 좀더 다양한 프로토콜을 열거할 수 있다. 하지만 우리가 만들 서버는 인터넷 TCP 프로토콜만을 사용하기 때문에 어느 것을 사용해도 문제없다. 단 WSASocket으로 소켓을 생성하는 경우 dwFlags 파라미터에 WSA_FLAG_OVERLAPPED를 넘겨 오버랩드 속성을 가지도록 해야 한다. 그렇지 않으면 이후 다룰 WSASend, WSARecv 등의 오버랩드 호출은 무시될 것이다. 오버랩드 I/O에 관한 내용은 추후 자세히 설명하겠다.
int bind(
SOCKET s,
const struct sockaddr FAR *name,
int namelen
);
빈 소켓만으로는 아무 것도 할 수 없다. 소켓과 로컬 주소를 연결시킨 뒤에야 비로소 네트워크 통신을 할 수 있는데, bind가 이러한 역할을 해 준다. 먼저 name 파라미터를 자세히 살펴보자(<리스트 2>).
sockaddr은 bind할 주소를 지정하는데 쓰이는 16바이트 크기의 구조체다. 소켓에는 다양한 주소 패밀리(AF_UNIX, AF_INET, AF_IMPLINK, ...)와 각각의 하위 프로토콜이 존재한다. 각 주소 패밀리에 따라 주소 지정 방법이 다를 수 있는데, 우리는 인터넷 프로토콜(AF_INET)을 사용하므로 AF_INET의 주소 지정을 쉽게 하기 위해 우측의 sockaddr_in을 사용한다.
보통 sockaddr_in의 sin_addr 필드에 ADDR_ANY를 집어넣는데, 이것은 멀티홈드 호스트(예 : 여러 LAN 카드가 꽂혀 있는 호스트)의 특정 네트워크 주소를 선택하지 않겠다는 뜻이다. 그러나 성능이나 보안 측면을 강화시키기 위해 특정 네트워크의 주소를 입력할 수 있다.
윈속이 제공하는 Name Resolution 함수 중 하나인 gethostb yname를 사용해 로컬 호스트의 네트워크를 열거할 수 있다.
HOSTENT *he = gethostbyname( host_name );
he->h_addr_list[0]; // 첫 번째(예 : LAN CARD #1)
he->h_addr_list[1]; // 두 번째(예 : LAN CARD #2)
he->h_addr_list[2]; // 세 번째(예 : CABLE MODEM)
다음으로 sockaddr_in의 포트를 살펴보자. 이 값이 0이면 시스템은 적당한 포트를 찾아 맵핑해 준다. 윈도우 2000에서의 기본 값은 1024~5000 사이의 값인데, 부족할 경우 TCP/IP 관련 레지스트리 키(MaxUserPort)의 최대 값을 변경할 수 있다.
MaxUserPort
Key: HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServicesTcpipParameters
Value Type: REG_DWORDmaximum port number
Valid Range: 500065534 (decimal)
Default: 0x1388 (5000 decimal)
이외에도 MaxFreeTcbs와 MaxHashTableSize 등을 조절해 맵핑될 소켓 수를 조절할 수 있다. 관심있는 독자는 「Microsoft Win dows 2000 TCP/IP Implementation Details」를 참고하기 바란다.
그리고 당연하겠지만 같은 주소로의 두 번째 bind는 실패한다. 간혹, 이전에 bind된 소켓을 분명히 닫았음에도 두 번째 bind가 실패하는 경우가 있는데, 이것은 이전 소켓이 실제로 완전히 닫히지 않고 TIME_WAIT 상태에 머물러 있기 때문이다. 서버를 재시작하는 경우에도 발생할 수 있는데, 이런 상황을 피하려면 setsockopt 함수를 사용해 SO_REUSEADDR 옵션을 셋팅하면 된다. TCP 상태에 대해 잘 모르고 있다면 TCP/IP 서적 등을 참고해 반드시 숙지하기 바란다.
int connect(
SOCKET s,
const struct sockaddr FAR *name,
int namelen
);
상대방 호스트에 접속하기 위해 connect를 호출한다. connect를 호출하기 위해 bind할 필요는 없다. 소켓을 생성한 뒤 바로 connect를 호출하면 자동으로 시스템이 지정하는 포트로 bind되는데, 이 역시 1024~5000 사이의 값을 가진다. 스트레스 테스트(stress test)를 하면 이 정도의 연결이 부족할 수 있다. 따라서 필요하다면 앞서 언급한대로 레지스트리 값을 적당한 값으로 설정해 주자.
int listen(
SOCKET s,
int backlog
);
listen은 소켓에 TCP 접속을 받아들일 수 있는 속성을 부여해 준다. backlog 파라미터는 동시에 접속이 몰렸을 때를 처리하기 위한 큐의 크기인데, 보통 시스템의 최대 값을 지정해서 쓴다. 윈속 2.0 이전 버전에서 이 값의 최대 값은 5였는데, 이것은 접속 요청이 최대 5개까지 큐될 수 있다는 것을 뜻한다. 윈속 2가 등장하면서 SO MAXCONN이라는 상수 값을 사용하는데, 내부적으로 윈도우 2000 서버는 200개, 프로는 5개까지 설정된다. 접속 처리를 위해 accept를 호출하면, backlog 큐의 첫 번째 노드가 삭제되면서 다른 접속 요청을 큐에 넣을 수 있다. backlog 큐가 가득 차면 클라이언트의 connect 호출은 WSAECONNREFUSED 에러를 리턴한다.
SOCKET accept(
SOCKET s,
struct sockaddr FAR *addr,
int FAR *addrlen
);
accept는 서버 소켓의 접속 큐에서 첫 번째 노드를 가져와 소켓을 생성한 뒤 리턴한다. 리턴된 소켓은 s 파라미터와 동일한 속성을 가진다는 것을 기억해 두자.
TCP 패킷을 주고받을 때 사용한다. 가장 빈번하게 호출되는 함수인 만큼 네트워크 개발자들이 주의해야 하는 부분이다. TCP는 신뢰할 수 있는(reliable) 스트림 기반의 프로토콜이다. 여기서 스트림 기반이라는 것에 주목할 필요가 있다. 수신자는 언제나 송신자가 전송한 만큼 받기 마련이지만, 이것이 곧 send, recv 함수의 호출 횟수까지 같다는 것을 뜻하지는 않는다.
전송된 패킷은 인터넷의 수많은 게이트웨이를 경유하면서 상대방에게 도착하는데, send 호출과는 관계없이 패킷이 뭉쳐오기도 하고 완전히 조각난 상태로 도착하기도 한다. TCP는 보낸 순서대로 끝까지 도착하는 것을 보장하는 것이지, 전송 횟수까지 보장하는 것은 아니다. 그야말로 스트리밍 송수신이다. 따라서 반드시 송수신자 간에 패킷의 완료 여부를 알 수 있도록 사인을 해 두어야 한다. 보통 패킷의 앞이나 뒤에 이를 확인할 수 있도록 구조를 잡는다. 다음의 의사 코드(pseudo code)는 일반적으로 소켓 수신을 처리하는 방법을 보여준다.
// TCP 수신 처리 방법
ret = recv( s, buf, sizeof( buf ), 0 );
if ( ret <= 0 ) // ret 에러 처리
// 패킷이 잘려 올 수 있기 때문에 이전 패킷과 합친다.
queue.add( buf, ret );
// 패킷이 뭉쳐 올 수도 있으므로 완료 패킷이 없어질 때까지 반복한다.
while ( queue.has_completion_packet() ){
process_completion_packet( queue.get_completion_packet() );
// 처리한 패킷은 큐에서 삭제한다.
queue.remove_completion_packet();}
또 다른 주의해야 할 점은 send, recv 함수의 리턴 값을 명확히 처리해 두는 것이다. 넌블러킹 소켓에서 send 호출은 우리가 생성한 버퍼(스택 또는 힙)를 커널 버퍼(소켓 버퍼)로 복사하고 커널 버퍼에 복사된 크기를 리턴한다. 이 때 커널 버퍼의 공간이 부족하여 요청한 크기와 리턴된 크기가 다를 수 있는데, 이런 경우 보통 네트워크 지연으로 판단해 접속을 끊거나 사용자가 만든 송신 큐에 임시로 보관해 두고 다음 송신이 가능해졌을 때 재전송하는 방법으로 해결한다. recv 함수는 보통 수신된 패킷 크기를 리턴하며, 0을 리턴하는 경우 정상적으로 접속이 종료되었다는 것으로 볼 수 있다. 단, 서버가 강제로 접속을 끊는 경우 recv는 SOCKET_ERROR를 리턴하면서 GetLastError() 함수로 WSAECONNRESET와 같은 에러 코드를 얻을 수 있다. 에러를 처리해 두면 send, recv 함수가 왜 실패했는지 명확해지기 때문에, 이후 네트워크 에러가 발생했을 때 어떻게 대처해야 할 것인가는 어렵지 않게 판단할 수 있다.
int shutdown(
SOCKET s,
int how
);
int closesocket(
SOCKET s
);
서버 프로그래밍을 할 때 주의해야 할 부분 중 하나가 소켓을 닫을 때의 처리이다. 안전하게 종료하기 위해서는 모든 데이터를 전송한 뒤 접속을 끊으려 할 때 shutdown 호출을 사용해 이 사실을 상대방에게 알려줘야 한다. 물론 상대방도 마찬가지다. 이러한 처리를 Graceful Closure라고 하며 <표 1>처럼 종료 처리를 한다(MSDN의 Graceful Shutdown, Linger Options, and Socket Closure 참조).
<표 1>과 같이 shutdown을 사용하면 남아 있는 데이터를 보낼 기회를 제공함으로써 소켓의 연결 종료를 제어할 수 있다. 그런데 아직 한 가지 고려해 볼 문제가 남아 있다. shutdown이나 closesocket 모두 ACK(TCP Handshake)를 확인하지 않고 리턴한다는 점이다. 그렇다면 어떻게 우리가 전송한 데이터가 정말로 보내졌는지 확인할 수 있을까?
Graceful Closure 설명을 하면서 Linger 옵션에 관한 설명을 빠뜨릴 수 없다. Linger 옵션은 closesocket 호출로 소켓이 닫히면서 남아 있는 데이터 전송을 어떻게 다룰 것인가를 설정한다. TCP는 상대방으로부터 보낸 패킷에 대한 ACK를 받아야 전송이 완료된 것으로 간주한다. <표 1>에서 서버 측을 보면 상대방으로부터 FIN 세그먼트를 확인한 뒤 데이터를 보내고 shutdown 호출 후 closesocket 호출로 마침내 소켓을 닫는다. 정상적인 과정이지만 내부적으로 FIN ACK는 물론 이전에 전송한 데이터조차 ACK를 받지 못했을 가능성이 있다. 다행히도 시스템은 기본적으로 소켓이 닫힌 후의 클로징 핸드세이크(Closing Handshake)를 처리할 시간(2MSL)을 준다.
Linger 옵션은 이 시간을 조절할 수 있게 하는데 일반적인 경우에 Linger 옵션을 설정할 필요는 없다. Linger를 설정하는 경우 블러킹 소켓에선 closesocket 호출시 블럭될 수 있고, 넌블러킹 소켓은 closesocket에서 WSAEWOULDBLOCK을 리턴하므로 완료되기까지 수차례 호출해야 한다는 단점이 있다. 간혹 이 시간을 0으로 설정하기도 하는데 이것을 하드 클로저(Hard Closure)라고 하며, 이 때 서버는 closesocket 즉시 해당 소켓에 관한 모든 리소스를 반납한다. 이 경우 상대방은 모든 데이터를 수신하지 못한 채 WSAECON NRESET 에러를 받기 때문에 특별한 경우가 아니라면 권장하지 않는다. <표 2>는 MSDN에서 발췌한 것으로 Linger 옵션에 따른 closesocket 작동 방식을 나타낸다.
오버랩드 I/O
오버랩드(overlapped) I/O란 문자 그대로 중첩된 입출력을 뜻한다. CPU에 비해 디스크나 통신 디바이스의 입출력에 걸리는 속도는 대단히 느리기 때문에 오버랩드 I/O를 사용해 디바이스 입출력시에 걸리는 시간 지연을 피할 수 있다. 물론 윈속은 이미 여러 가지 비동기 입출력 방법을 제공하고 있어, 굳이 오버랩드 I/O를 사용하지 않더라도 거의 같은 성능의 비동기 입출력을 구현할 수 있다. 잠시 후 소개할 IOCP(IO Completion Port)와 함께 사용되기 때문에 한 번쯤 거쳐야 할 관문 정도로만 생각해 두고 부담없이 진행해 나가도록 하자. send, recv 대신 WSASend, WSARecv를 사용해 오버랩드 I/O를 할 수 있다.
함수 파라미터의 구체적인 사용법은 이후에 논하기로 하고, 지금은 WSAOVERLAPPED 구조체를 사용해 함수를 호출한다는 것 정도만 알아두자. 넌블러킹 소켓에서와 마찬가지로 오버랩드를 사용한 WSASend, WSARecv 호출은 특별한 이상이 없는 한 WSAE WOULDBLOCK을 리턴한다. 오버랩드 I/O의 완료 여부를 확인하려면 다음의 함수를 호출하면 된다.
BOOL WSAGetOverlappedResult(
SOCKET s,
LPWSAOVERLAPPED lpOverlapped,
LPDWORD lpcbTransfer,
BOOL fWait,
LPDWORD lpdwFlags
);
사실은 바로 IOCP를 설명해도 되지만, 오버랩드 I/O를 설명하면서 그냥 지나치면 WSAGetOverlappedResult가 섭섭해 할까봐 한번 등장시켜 보았다. 바로 뒤에 설명할 IOCP를 사용해 완료 통보를 받게 되면, 더 이상 이 함수는 설 자리가 없어지기 때문에 독자의 기억 속에 그리 오래 머물 것 같진 않다. 오버랩드 I/O의 다양한 쓰임새나 윈속의 다른 비동기 입출력 방법에 대해 자세히 알고 싶다면, 마이크로소프트 프레스의 「Network Programming for Microsoft Windows」를 참고하기 바란다.
디바이스 입출력 완료 통보 포트, IOCP
IOCP는 디바이스의 입출력 완료를 통보하기 위한 포트로서, 빠른 입출력 통보 외에 최적화된 쓰레드 풀링 기술을 포함하고 있다. 디바이스와 IOCP를 연결하는 데 개수 제한이 없고, 최적화된 쓰레드 풀링을 통해 고성능 서버를 구축하는 데 큰 도움이 되기 때문에, 현재 많은 윈도우 서버 프로그래머들의 사랑을 받고 있는 귀여운 녀석이기도 하다. 제공되는 성능에 비해 사용법 자체는 의외로 간단해 프로그래머는 IOCP를 만들고, 적절한 수의 워커 쓰레드를 생성한 다음 입출력 완료 통보를 기다리기만 하면 된다.
HANDLE CreateIoCompletionPort (
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreads
);
IOCP를 만들어 주는 좀 웃기는(?) 함수다. 이 함수는 사실상 두 가지 역할을 하는데, 하나는 이름 그대로 IOCP를 생성하는 것이고(네 번째 파라미터만 사용), 다른 하나는 오버랩드 속성을 지닌 소켓과 IOCP를 연결하는 것이다(앞의 세 파라미터만 사용). 「Program ming Server-Side Applications for Microsoft Windows 2000」의 저자 제프리 리처(Jeffrey Richter)도 언급한 것이지만, 함수를 왜 저렇게 만들어 놨는지 도저히 이해되지 않는 부분이다. 어쨌든 꽤 중요한 함수이기에 다음의 일련의 흐름을 보면서 IOCP 체계를 확실히 이해해 둘 필요가 있다.
짾 IOCP를 만든다
HANDLE h = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, 0, 0 );
처음엔 당연히 IOCP를 만들어야 한다. 먼저 IOCP를 만들 때는 앞의 세 파라미터가 쓰이지 않으므로 가볍게 INVALI D_HANDLE_ VALUE, 0, 0을 넘겨주자. NumberOfConcurrent Threads에 오버랩드 I/O를 처리하기 위해 동시에 실행할 수 있는 쓰레드의 수를 지정하는데, 0을 넘기면 시스템은 설치된 프로세서(CPU)의 수만큼 할당한다.
짿 IOCP를 감시할 쓰레드를 생성한다
SYSTEM_INFO si
GetSystemInfo( &si );
numThreads = si.dwNumberOfProcessors * 2;
for ( i = 0; i < numThreads; i++ )
_beginthreadex( NULL, 0, WorkerThread, ... );
IOCP의 완료 통보를 받을 쓰레드를 생성한다. 좀 이상한 부분이 눈에 띄지 않는가? IOCP를 만들 때 CPU 수만큼의 쓰레드가 동시에 돌아갈 수 있도록 그 수를 제한해 놓고선, 정작 쓰레드는 그 두 배만큼 만들고 있다. 이는 워크 쓰레드가 Wait 상태에 다다를 때(예 : Sleep 호출) IOCP가 또 다른 쓰레드에 완료 통보를 해주기 때문에 여분의 쓰레드를 미리 만들어 두는 것이다. 두 배라고 한 것은 필자 맘대로 정한 수치이고 서버의 구현 방법이나 서비스 내용에 따라 적절한 값을 찾는 것이 좋다.
쨁 소켓과 IOCP를 연결시킨다
CreateIoCompletionPort( (HANDLE) my_socket, iocp_handle, completion_key, 0 )
오버랩드 I/O를 IOCP로 통보받기 위해 소켓 핸들과 IOCP 핸들을 연결시켜야 한다. 세 번째 파라미터인 completion_key는 나중에 오버랩드 I/O에 대한 완료 통보를 받을 때, 어떤 소켓으로부터의 완료 통보인지 식별할 수 있게 해주는 것으로 보통 소켓을 포함하고 있는 객체의 주소를 넘긴다. 그리고 앞서 언급했듯 마지막 파라미터는 쓰지 않는다.
쨂 IOCP를 감시(+_+)한다
WorkerThread()
{
while ( TRUE )
{
GetQueuedCompletionStatus(
iocp_handle, // HANDLE CompletionPort
&bytes_transferred, // LPDWORD lpNumberOfBytes
&completion_key, // PULONG_PTR lpCompletionKey
&overlapped, // LPOVERLAPPED *lpOverlapped
INFINITE ); // WORD dwMilliseconds
// completion_key와 오버랩드를 보면
// 어떤 소켓의 오버랩드 I/O인지 구별할 수 있다.
}
};
처음 보는 함수가 나타났다. 이미 독자도 예상하고 있겠지만 Get QueuedCompletionStatus가 IOCP의 부름(Thread Wake-Up)을 받기 위해 기다리고 있다. 이 함수를 통해 어떤 소켓의 어떤 호출인지, 또 얼마만큼 전송이 되었고 에러 코드는 무엇인지 등을 확인할 수 있다.
쨃 WSASend, WSARecv 등의 오버랩드 I/O를 시작한다
WSASend(
s, &wsabuf, 1,
&bytes_transferred, 0, &overlapped, NULL );
WSARecv(
s, &wsabuf, 1,
&bytes_transferred, &flag, &overlapped, NULL );
패킷을 주고받기 위해 오버랩드 구조체를 이용한다. WSASend, WSARecv 각각의 파라미터에 주의를 기울일 필요가 있는데, 이에 관한 자세한 설명은 다음 번에 직접 네트워크 라이브러리를 구현하면서 자세히 설명하기로 하고 지금은 IOCP 체계를 이해하는 것에 초점을 맞추자.
두 개의 CPU가 설치된 윈도우 2000에서 <그림 2>와 같은 IOCP 서버가 실행 중이라고 가정해 보자. IOCP를 만들 때 NumberOf ConcurrentThreads에 0을 넘겨 동시 쓰레드(concurrent thread)의 수가 두 개가 되도록 했다. #1은 이미 완료 통보를 받아 해당 객체의 송수신을 처리 중이고, #2가 지금 막 완료 통보를 받고 있다. 이렇게 되면 정확히 두 개의 쓰레드가 동시에 실행중인 것이며, IOCP 큐에 완료 통보가 도착하더라도 IOCP는 다른 쓰레드(#3)에 완료 통보를 하지 않는다. 이 시점에서 발생할 수 있는 두 가지 시나리오를 세워 보았다.
◆ 시나리오 1 - #1이 완료 통보 처리를 마침
완료 처리가 끝났기 때문에, #1은 다시 GetQueuedCompletion Status 함수를 호출한다. 이때 IOCP는 큐에 쌓여 있던 다른 완료 통보를 다시 #1에 넘겨준다. 먼저 기다리고 있던 #3에 넘기지 않는 이유는 쓰레드 컨텍스트 스위칭을 줄이기 위해서다.
◆ 시나리오 2 - #1이 처리 도중 Sleep을 호출
프로그래머가 무슨 생각으로 Sleep을 호출했는지는 모르겠지만 어쨌든 쓰레드 Wait 상태에 돌입한다. 이 때 기다리고 있던 #3이 IOCP로부터 완료 통보를 받는다. 이 시점의 실제 동시 쓰레드 수는 2+1(Wait State)이며, #1이 잠에서 깨어날 경우 순간적으로 IOCP를 만들 때 지정했던 쓰레드 수의 범위를 초과할 수 있다. 이후 IOCP는 다시 동시에 실행될 쓰레드 수가 2가 되도록 조절한다. 이러한 이유로 IOCP 생성시에 지정해 준 NumberOfConcurrentThreads의 수보다 실제로 많은 워커 쓰레드를 생성하는 것이다.
IOCP를 이용한 서버 구현시 주의사항
많은 개발자들이 범하는 대부분의 실수는 멀티 쓰레드와 비동기 입출력의 이해 부족에 기인한다. 멀티 쓰레드 프로그래밍만 하더라도 어렵고 복잡한데, 여기에 비동기 입출력까지 더해지니 네트워크 개발자들이 겪을 그 혼란은 충분히 짐작할 만하다. 이번엔 IOCP를 이용해 서버 네트워크 코드를 구현할 때 특히 주의해야 점을 알아보기로 하자.
에러 코드를 반드시 확인한다
WSASend, WSARecv 등을 통해 오버랩드 I/O를 할 때 정상적인 경우 WSAEWOULDBLOCK을 리턴한다. 그러나 원격 호스트가 접속을 끊거나(WSAECONNRESET), 가상 회선에 문제가 발생했을 때(WSAECONNABORTED)와 같은 문제는 빈번히 발생한다. 이 경우 별 수 없이 이쪽에서도 접속을 끊는 수밖에 없다. 골치 아픈 부분은 WSAENOBUFS와 같은 에러를 만나는 경우다. 다음 호에서 구현을 통해 자세히 알아보겠지만, 시스템 리소스(커널 리소스) 제한에 걸리게 되면 오버랩드 I/O는 ‘WSAENOBUFS 에러’를 내뱉으며 실패한다. 마찬가지로 ‘그냥 접속을 끊으면 되는 것 아니냐?’고 반문하겠지만, 그것이 클라이언트가 아니라 대량의 클라이언트가 접속한 상황에서의 서버간 송수신에서 발생하는 것이라면 더욱 심각해진다. 대량의 클라이언트가 접속한 상황에서는 언제든지 시스템 리소스가 바닥날 수 있기 때문에 클라이언트의 연결을 적절히 분산시킬 수 있는 메커니즘이 필요하며, 불가피한 경우 클라이언트의 접속을 제한해야 한다.
참조 카운트를 유지한다
오버랩드 호출을 걸어두고, 완료 통보를 받기도 전에 오버랩드 버퍼나 소켓 객체가 삭제돼서는 안된다. 또한 한 객체에 대해 둘 이상의 오버랩드 호출이 있는 경우엔 반드시 참조 카운트를 유지해야 하며, 객체를 제거해야 하는 경우에 이 참조 카운트가 0인지 확인해야 한다. 참조 카운트를 유지하지 않고 완료 통보가 아직 더 남아있는 상태에서 객체를 삭제하면, 당연한 것이지만 그 다음 완료 통보시 엉뚱한 메모리 위치를(IOCP로 말하자면 CompletionKey나 Overlapped Pointer) 가리켜 크래시를 발생시킨다. 원인을 모르고 객체가 삭제된 것에 분개해 정적 메모리 관리 등으로 당장 급한 불을 끄는 것은 근본적인 해결책이 될 수 없다.
데드락을 주의한다
IOCP의 워커 쓰레드만을 이용해 서비스 코드를 구현할 때 주의해야 할 사항이 있다. 주로 샘플 소스로 쓰이는 에코(echo) 서버나, 실제로 IOCP로 구현되어 있는 IIS(Internet Information Server)와 같은 서버는 객체간 상호 참조가 발생되지 않아 이러한 문제는 없다. 그러나 채팅 서버와 같은 상호 참조(즉, 한 객체가 다른 객체에 직접적인 접근이 일어나는 것)가 발생하는 서비스에서는 양방향 상호 참조가 동시에 일어나는 경우에 데드락(dead-lock)이 발생할 수 있다. 따라서 동기화에 각고의 노력을 기울여야 하며, 이것보다는 패킷을 처리하는 전용 쓰레드를 따로 두어 일괄적으로 처리하는 방법을 권한다.
다양한 의견 기다리며
이번 호에서는 본격적인 구현에 앞서 필요한 내용들을 쭉 살펴봤다. 지면 관계상 조금 빠르게 진행된 감이 있는데 부족한 부분은 참고자료를 살펴보기 바란다. 필자도 부족한 부분이 많기 때문에 오해하고 있는 부분이 있거나, 잘못된 코드를 제공할 수 있다. 이런 부분이 발견되면 즉시 연락해 바로잡을 수 있도록 도와주길 바란다. 그리고 이번 기사에 대한 질책이나 조언, 다양한 의견을 접할 수 있다면 앞으로 좋은 기사를 쓰는 데 큰 도움이 될 것이다. 부담없이 연락해 주길..(^^;)
[출처] 윈속 프로그래밍 리뷰|작성자 poins
[socket] IOCP 서버 제작에 있어서의 유의할 점들
IOCP 서버 제작에 있어서의 유의할 점들
현재 개발중인 게임 서버의 소켓이 4년전에 제작한 비동기 이벤트 셀렉트 방식인 관계로 퍼포먼스를 향상시키고자 IOCP 네트워크 구조를 최근 제작하게 되었다. 실제 게임서버에 적용 가능할지는 좀 더 고려해 보아야 하겠지만, IOCP 서버를 만들면서 겪었던 점들을 공유하고자 한다.
시중의 책들과 공개된 소스들을 참고해서 IOCP를 구현해 보면, 항상 과부하 테스트시에 문제가 발생했다. 내가 설정한 과부하 테스트는 다음과 같은 상황이다:
1) 클라이언트 측에서 과도할 정도로 Connect를 시도한다.
2) 서버는 Accept 직후 랜덤하게 연결을 끊어버린다.
3) 클라이언트는 Connect된 직후 서버로 데이터를 전송한다.
4) 서버는 클라이언트로부터 데이터가 수신되면 바로 응답메시지를 전송한다. 이때 응답메시지를 전송할 내부 버퍼(소켓 버퍼 아님)가 모자라는 경우 연결을 끊는다. 이 처리와는 별도로 데이터 수신시 랜덤하게 연결을 끊는다.
5) 클라이언트는 서버로부터 데이터를 수신하면 바로 응답메시지를 전송한다. 이때 응답메시지를 전송할 내부 버퍼(소켓 버퍼 아님)가 모자라는 경우 연결을 끊는다. 이 처리와는 별도로 데이터 수신시 랜덤하게 연결을 끊는다.
6) 클라이언트는 연결이 끊어진 커넥션이 발생하면 그에 대응하는 Connect를 시도한다.
클라이언트는 초기에 몇천개 이상의 Connect를 시도하고 연결된 커넥션들에 대해 각각 위의 규칙대로 처리를 반복하는 상황을 만들어 테스트 해 보았는데, 여러번의 삽질끝에 발견한 문제점들은 다음과 같다.
일단, 시중에 떠도는 IOCP소스들의 대부분은 에코(Echo) 서버들이다. 이 소스들은 항상 데이터를 recv한 다음 send를 하므로 참조 카운트가 1 이상 올라가지 않지만, 게임 서버는 그렇지 않다. 걸어놓은 recv에 대한 응답이 안 온 상태에서 send를 할 수 있으므로 소켓 1개에 대해 2개의 참조 카운트가 발생할 수 있다. GetQueuedCompletionStatus가 FALSE를 리턴한 경우, 대부분의 에코서버 소스에서는 바로 소켓을 close한 다음 커넥션 객체를 삭제해 버리는데, 이것은 참조 카운트가 1이상 올라가지 않기 때문이다. 이런 경우 게임서버에서는 그 소켓에 대한 참조카운트가 2라면, 그냥 소켓을 close한 다음 나머지 작업에 대한 실패 통보가 와서 참조 카운트가 0이 되었을때 커넥션 객체를 삭제해야만 한다. 마찬가지로 WSARecv에 대한 리턴이 왔는데 전송 바이트가 0인 경우에도 무조건 객체를 지워서는 안된다.
IOCP에서 WSASend, WSARecv를 사용할 때 소켓에러 없이(IO_PENDING는 에러가 아니므로 제외) 포스팅된 소켓 연산의 결과는 반드시 각각 GetQueuedCompletionStatus에서 리턴된다. 단, 함수의 반환값은 TRUE일수도 FALSE일수도 있다.
한가지 이상한 현상을 발견했는데, close한 소켓을 WSASend나 WSARecv에 사용했을때 에러가 반환되지 않는 경우가 있다. 예를 들어, 소켓 A에 대해서 WSARecv가 포스팅된 상태에서, 소켓 A를 close했다. 이 때 GetQueue... 에서 WSARecv에 대한 성공 결과가 리턴될 수 있다(close하기 전에 성공한 결과일 수 있으므로), 이때 다시 WSARecv를 걸려고 할 때 close된 소켓이므로 WSARecv가 에러를 발생해야 정상이므로 이 시점에 커넥션을 삭제하고자 했는데, WSARecv가 에러를 발생시키지 않는 거다. 원인은 잘 모르겠지만, 아마도 네트워크 과부하 상황일때 이런 현상이 발생하는게 아닌가 추측하고 있다. 결국 결국 소켓이 닫혔다는 별도의 플래그를 만들어 직접 해결할 수 밖에 없었다. 이런 상황에서 얻은 정보를 써 보자면:
서버에서 소켓을 close하는 것이 closesocket() 호출 이전에 포스팅한 WSASend, WSARecv를 꼭 실패시키지는 않는다. 성공할 수도 있고, 실패할 수도 있다.
서버에서 소켓을 close하면 이전에 포스팅된 연산에 대한 결과는 반드시 GetQueuedCompletionStatus에서 각각 모두 리턴된다.
이런 것들을 이해하고 나니, 과부하 테스트에서도 정확하게 동작하는 IOCP 네트워크 클래스를 제작할 수 있었다.
현재 개발중인 게임 서버의 소켓이 4년전에 제작한 비동기 이벤트 셀렉트 방식인 관계로 퍼포먼스를 향상시키고자 IOCP 네트워크 구조를 최근 제작하게 되었다. 실제 게임서버에 적용 가능할지는 좀 더 고려해 보아야 하겠지만, IOCP 서버를 만들면서 겪었던 점들을 공유하고자 한다.
시중의 책들과 공개된 소스들을 참고해서 IOCP를 구현해 보면, 항상 과부하 테스트시에 문제가 발생했다. 내가 설정한 과부하 테스트는 다음과 같은 상황이다:
1) 클라이언트 측에서 과도할 정도로 Connect를 시도한다.
2) 서버는 Accept 직후 랜덤하게 연결을 끊어버린다.
3) 클라이언트는 Connect된 직후 서버로 데이터를 전송한다.
4) 서버는 클라이언트로부터 데이터가 수신되면 바로 응답메시지를 전송한다. 이때 응답메시지를 전송할 내부 버퍼(소켓 버퍼 아님)가 모자라는 경우 연결을 끊는다. 이 처리와는 별도로 데이터 수신시 랜덤하게 연결을 끊는다.
5) 클라이언트는 서버로부터 데이터를 수신하면 바로 응답메시지를 전송한다. 이때 응답메시지를 전송할 내부 버퍼(소켓 버퍼 아님)가 모자라는 경우 연결을 끊는다. 이 처리와는 별도로 데이터 수신시 랜덤하게 연결을 끊는다.
6) 클라이언트는 연결이 끊어진 커넥션이 발생하면 그에 대응하는 Connect를 시도한다.
클라이언트는 초기에 몇천개 이상의 Connect를 시도하고 연결된 커넥션들에 대해 각각 위의 규칙대로 처리를 반복하는 상황을 만들어 테스트 해 보았는데, 여러번의 삽질끝에 발견한 문제점들은 다음과 같다.
일단, 시중에 떠도는 IOCP소스들의 대부분은 에코(Echo) 서버들이다. 이 소스들은 항상 데이터를 recv한 다음 send를 하므로 참조 카운트가 1 이상 올라가지 않지만, 게임 서버는 그렇지 않다. 걸어놓은 recv에 대한 응답이 안 온 상태에서 send를 할 수 있으므로 소켓 1개에 대해 2개의 참조 카운트가 발생할 수 있다. GetQueuedCompletionStatus가 FALSE를 리턴한 경우, 대부분의 에코서버 소스에서는 바로 소켓을 close한 다음 커넥션 객체를 삭제해 버리는데, 이것은 참조 카운트가 1이상 올라가지 않기 때문이다. 이런 경우 게임서버에서는 그 소켓에 대한 참조카운트가 2라면, 그냥 소켓을 close한 다음 나머지 작업에 대한 실패 통보가 와서 참조 카운트가 0이 되었을때 커넥션 객체를 삭제해야만 한다. 마찬가지로 WSARecv에 대한 리턴이 왔는데 전송 바이트가 0인 경우에도 무조건 객체를 지워서는 안된다.
IOCP에서 WSASend, WSARecv를 사용할 때 소켓에러 없이(IO_PENDING는 에러가 아니므로 제외) 포스팅된 소켓 연산의 결과는 반드시 각각 GetQueuedCompletionStatus에서 리턴된다. 단, 함수의 반환값은 TRUE일수도 FALSE일수도 있다.
한가지 이상한 현상을 발견했는데, close한 소켓을 WSASend나 WSARecv에 사용했을때 에러가 반환되지 않는 경우가 있다. 예를 들어, 소켓 A에 대해서 WSARecv가 포스팅된 상태에서, 소켓 A를 close했다. 이 때 GetQueue... 에서 WSARecv에 대한 성공 결과가 리턴될 수 있다(close하기 전에 성공한 결과일 수 있으므로), 이때 다시 WSARecv를 걸려고 할 때 close된 소켓이므로 WSARecv가 에러를 발생해야 정상이므로 이 시점에 커넥션을 삭제하고자 했는데, WSARecv가 에러를 발생시키지 않는 거다. 원인은 잘 모르겠지만, 아마도 네트워크 과부하 상황일때 이런 현상이 발생하는게 아닌가 추측하고 있다. 결국 결국 소켓이 닫혔다는 별도의 플래그를 만들어 직접 해결할 수 밖에 없었다. 이런 상황에서 얻은 정보를 써 보자면:
서버에서 소켓을 close하는 것이 closesocket() 호출 이전에 포스팅한 WSASend, WSARecv를 꼭 실패시키지는 않는다. 성공할 수도 있고, 실패할 수도 있다.
서버에서 소켓을 close하면 이전에 포스팅된 연산에 대한 결과는 반드시 GetQueuedCompletionStatus에서 각각 모두 리턴된다.
이런 것들을 이해하고 나니, 과부하 테스트에서도 정확하게 동작하는 IOCP 네트워크 클래스를 제작할 수 있었다.
2009년 7월 6일 월요일
[socket] Socket Programming with MFC in Win32 Environment
Socket Programming with MFC in Win32 Environment
March 11, 2008 — Digital Angel Master
Socket Programming with MFC in Win32 Environment
2002년 5월 9일 윤성진(chaos@ChaosClub.net) http://ChaosClub.net
서론
네트웍 프로그래밍이라는 것은 단순히 데이터를 처리하는 수준의 application과는 좀 다른 면이 있다. 어쩌면 어느 정도 시스템 프로그래밍이라고 할 수 있다. 이 말은 그 네트웍 프로그램이 실행될 환경, 즉 운영 체제를 이해해야 할 뿐만 아니라, TCP/IP에 대한 기본적인 지식이 필요하다는 뜻이다. 필자 역시 여러 언어로 네트웍 프로그래밍을 해봤는데, 역시 네트웍 프로그래밍은 쉽지만은 않다. 기 본적으로 알아야 할 것도 많고 실제 프로그래밍을 할 때에 생각해야 할 것도 많다. 네트웍 프로그램은 한대의 컴퓨터에서만 실행되는 것이 아니라, 최소한 2대 이상의 컴퓨터에서 상호 작용을 통해 작동하 고, 컴퓨터 외적인 요소에도 영향을 많이 받는다. 예를 들면 네트웍 회선이 불량하다든가, 잘못된 방화 벽 설정, 라우터 설정 등으로 인한 문제, RFC 문서 1를 준수하지 않는 상대측 네트웍 프로그램과의 호 환성 문제 등 실제로 프로그래밍을 하다보면 외적인 요소에 많은 영향을 받게된다. 따라서 필자 역시 그러한 부분에서 많은 어려움을 겪었고 네트웍 프로그래밍에 입문하는 다른 분들에 게 도움이 되고자 이 글을 남기기로 결심했다. 만약 이 글을 읽는 여러분이 인터넷 사용에 익숙하고 유닉스/리눅스 등을 어느 정도 다룬다면 이 글을 보는 데에 많은 도움이 될 것이다. 물론 이 글은 MS-Windows 환경에서의 네트웍 프로그래밍을 다 루지만 리눅스 등을 많이 사용해 보았다면 IP 주소라든가 DNS, 포트 번호 등과 같은 단어에 익숙할 것이다. 이 글을 읽는 독자의 수준은 C/C++ 중급 이상의 수준, 그리고 기본적인 네트웍 상식과 MFC를 이용 한 간단한 프로그램 작성 수준이면 된다. 너무 높은 수준을 요구한다고 생각되면 미리 공부를 좀 더 해볼 필요가 있겠다.
필자는 현재 대전 대덕연구단지에 위치한 모 벤처기업의 구석 자리에 앉아서 이 글을 쓰고 있다. 병역 특례 자리를 마련해준 서울의 박사장님, 그리고 여기 대전 벤처기업의 변사장님께 이렇게 여유있는 시 간을 주심에 대해 감사드리는 바이다.
2002년 5월 8일 윤성진 (chaos@chaosclub.net)
1 RFC 문서란 Request For Comments란 뜻인데, 인터넷과 관련된 여러 규약을 정리한 문서라고 생각하면 된다. 쉽게 말해 인터넷에서 사용되는 많은 프로토콜들의 표준을 정의한 문서이다. 이 문서 는 http://www.ietf.org에 가면 검색이 가능하다. 대중적인 프로토콜을 이용한 프로그램을 개발할 때에는 꼭 RFC 문서를 참조하여 표준을 지켜야 한다.
1. 네트워크 기초 및 TCP/IP
여기서는 간단한 TCP/IP 이론에 대해 소개한다. 꼭 필요한 수준만 논하고 있으니 더 깊은 이해 를 원하는 사람은 기타 서적을 참고하기 바란다.
1.1 서버(server)와 클라이언트(client) “클라이언트”와 “서버”라는 말이 자주 나오게 될텐데, 초보자를 위해 설명하자면 다음과 같다. 서버나 클라이언트는 모두 네트워크에 연결된 컴퓨터를 가리킨다. 다만 네트워크에 묶여져 있을 때 그 컴퓨터가 하는 “역할”에 따라 서버와 클라이언트를 구분한다. 서버(server)는 하인이라는 뜻 의 영어 단어인데, 어떤 작업 요청을 받아들여서 그것을 처리한 뒤에, 결과를 되돌려주는 일을 하 는 컴퓨터를 말한다. 클라이언트는 이러한 서버에게 작업을 의뢰하고 결과를 받아가는 역할을 하는 컴퓨터를 말한다. 어떤 책에서는 “세션을 초기화하는 쪽 ”을 클라이언트라고 정의한다. 이것도 좀 난해하긴 하지만 그럴듯한 정의이다. 일반적으로 서버용 컴퓨터는 클라이언트보다 성능이 상당히 뛰어나지만 꼭 그런 것만은 아니다. 그 역할에 따라 서버와 클라이언트를 구분한다는 것을 명심하자. 예를 들어 보면 우리가 야후 사이트 에 접속해서 웹페이지를 받아오는 것도 서버와 클라이언트의 관계가 성립되는데, 우리의 컴퓨터가 야후에 접속해서 웹페이지를 “요청”하므로 우리의 컴퓨터는 클라이언트가 되고 야후의 컴퓨터가 서버가 되는 것이다. 이런 경우에 야후 서버는 웹 서비스를 제공하므로 특별히 “웹서버”라는 말 도 사용한다. 요즘 특이한 경우로 PtoP라는 것이 있다. 이것은 개인용 컴퓨터끼리 직접 연결해서 서로 자료를 주고 받는다든가 하는 것을 말하는데, 구루구루나 소리바다 같은 것이 PtoP(Peer to Peer)이다. 이런 방식의 경우에는 각 컴퓨터가 서버의 역할과 클라이언트의 역할을 동시에 한다. 꼭 이런 것이 아니더라도 우리의 컴퓨터가 서버의 역할과 클라이언트의 역할을 동시에 할 수 있다. 예를 들면 우리가 윈도우 2000 컴퓨터에서 FTP를 열어놓고 친구에게 자료를 받아가게 해놓으면 서 동시에 그 컴퓨터를 이용해서 웹서핑을 할수도 있는데, FTP 서비스면에서 보면 서버의 역할을, 웹서핑 관점에서 보면 클라이언트의 역할을 하는 것이다. 참고로 서버와 클라이언트 외에 “호스트”라는 말도 네트워크 서적에서 많이 나오는데, 이 말은 네트워크의 말단에 연결된 컴퓨터를 말하는 것이다. 따라서 일반적으로 서버나 클라이언트들은 모 두 호스트이다. 그러나 네트워크 중심(core)에 위치한 스위치라든가 라우터와 같은 장비들은 호스 트가 아니다.
1.2 프로토콜 스택 일단 프로토콜이라는 말에 대해 정의해 보면 다음과 같다. 프로토콜이란 “네트워크 소프트웨어의 핵심으로 둘 이상의 통신 개체 사이에 교환되는 메시지의 형태, 의미, 전송순서, 그리고 메시지 송 수신 및 기타 사건에 수행할 동작을 정의한 규약”이다. 흔히 전송 규약이라고 많이 해석을 하는데, 서버와 클라이언트가 서로 메시지를 어떻게 주고 받으며, 또 특정한 메시지를 받았을때에는 어떤
동작을 취해야 하는지를 모두 정의해 놓은 것을 전송 규약이라고 한다. 프로토콜에는 아래에서 설 명할 TCP, UDP, IP와 같이 인터넷 프로토콜 스택을 구성하는 것도 있지만 응용 계층에서 설계 되고 동작하는 응용 계층 프로토콜들도 있다. 응용 계층 프로토콜에 해당하는 것이 HTTP, FTP, SMTP, POP3, IMAP 등이다. 이러한 응용 계층 프로토콜은 여러분도 얼마든지 독자적으로 설계 할 수 있다. 인터넷에서 사용되는 프로토콜은 TCP/IP라고 한다. 이것은 인터넷에서 사용되는 프로토콜인 TCP와 IP를 함께 부르는 말이다. 이 인터넷 프로토콜은 다섯 계층으로 분리되는데, 하부에서부터 물리(physical), 링크(link), 네트워크(network), 전송(transport), 응용(application) 계층으 로 나뉜다. 어떤 자료에는 물리 계층과 링크 계층을 합쳐서 그냥 링크 계층이라고 하기도 한다. 이 런 경우에는 4 계층이 된다. 이렇게 인터넷 프로토콜은 계층 구조를 이루는데, 이런 계층 구조를 프로토콜 스택이라고 한다. 인터넷 프로토콜 스택을 상위에서부터 살펴보면 다음과 같다.
▪ 응용 계층 (application layer) 응용 계층이란 우리가 흔히 사용하는 네트웍 프로그램들을 뜻한다. 여기에는 인터넷 익스플로러, 알FTP, 구루구루 등이 해당한다. 우리가 만드는 프로그램도 바로 이 응용 계층에 해당한다.
▪ 트랜스포트 계층 (transport layer) 트랜스포트 계층은 응용 계층에서 넘겨준 데이터를 다시 하위 계층인 네트워크 계층에 넘겨준다. 이 계층에는 2가지 프로토콜이 있는데, TCP(Transmission Control Protocol)와 UDP(User Datagram Protocol)이 있다. TCP는 연결지향형(connection-oriented) 서비스라고 하는데, 이것은 목적지로의 데이터 전달 보장과 흐름제어(flow control) 기능을 제공한다. 그리고 UDP는 비연결형(connectionless) 서비스로서 데이터가 목적지에 반드시 전달된다거나 순서대로 전달된 다는 보장을 하지 않는다. 일반적으로 TCP를 많이 사용한다. 우리가 제작할 프로그램도 TCP를 사용한다. UDP는 매우 한 정적으로 사용되는데 대표적으로 온라인 음악 방송이나 동화상 서비스 등이 UDP를 사용한다. UDP는 데이터의 오류도 체크하지 않고 패킷의 순서조차 보장하지 않음에도 장점이 한가지 있는 데 속도가 TCP에 비해 빠르다는 것이다. 그래서 패킷 1 몇 개가 없어져도 큰 상관이 없는 실시간 방송 등에는 UDP가 사용되는 것이다. 이런 특수한 경우를 제외하면 모두 TCP를 사용한다고 보 면 된다. 우리가 TCP를 이용하여 네트웍 프로그램을 만들면 우리가 전송하는 데이터는 틀림없이 수신측에 도착하며 패킷의 순서도 보낸 순서와 동일함을 보장받을 수 있다. 이 알고리즘은 놀라울 정도로 정 교하고 치밀하게 설계되어 있다.
1 패킷(packet)이란 네트워크를 통해 데이터를 전달할 때 일정한 크기의 묶음으로 전달을 하게 되는 데 이것을 패킷이라고 한다. 패킷에는 헤더와 데이터 부분이 있어서 헤더에는 패킷의 송신지 정보, 수 신지 정보 등이 담겨지게 된다.
▪ 네트워크 계층 (network layer) 네트워크 계층 프로토콜은 2가지 요소를 갖는다. 우선 IP 데이터그램의 필드를 정의하고 종단 시 스템(end system)과 라우터가 이들 필드에 어떻게 동작하는지를 정의하는 프로토콜을 가지는데, 이것이 IP(Internet Protocol)이다. 또한 소스와 목적지 사이에 데이터그램이 가야하는 경로를 결정하는 라우팅 프로토콜을 수행한다. 이 계층을 흔히 IP 계층이라고도 한다.
▪ 링크 계층 (link layer) 링크 계층은 패킷을 실제로 다음 노드로 전송해주는 계층이다. 예를 들면 랜과 같은 이더넷 (Ethernet)같은 것이 있다.
▪ 물리 계층 간혹 인터넷 프로토콜 스택을 4계층으로 구분할 때 이 물리 계층은 링크 계층에 포함되기도 한다. 이것은 데이터 통신의 가장 밑바닥으로서 동축케이블이라든가, 광케이블과 같은 물리적인 계층에서 비트 수준의 데이터 전송을 뜻한다.
링크 계층과 물리 계층은 다분히 하드웨어적인 분야로서 우리가 어떻게 다룰 수 있는 것은 아니다. 실제로 우리가 다룰 수 있는 것은 응용 계층 뿐이며, 특별히 드라이버 개발자라든가 운영체제 개발 자에 한해 전송 계층이나 네트워크 계층은 접근할 수가 있을 것이다.
1.3 소켓(socket) 우리가 흔히 네트워크 프로그래밍을 한다는 것을 소켓 프로그래밍이라고 얘기를 한다. 소켓이란 응 용 계층과 전송 계층 사이의 API(Application Programming Interface) 1이다. 즉 우리가 응 용 프로그램을 개발할 때 에러 검출이라든가 패킷의 순서를 맞춘다든가 하는 일을 우리가 하는 것 이 아니라, 소켓을 사용하여 전송 계층으로 넘겨주기만 하면 그 다음부터는 알아서 흐름 제어라든 가 에러 검출 및 재전송 등이 자동으로 이루어지게 된다. 물론 정확한 순서로 틀림없이 데이터가 전송된다는 것은 TCP를 사용할 때의 이야기이다. UDP를 사용하면 그런 서비스를 이용할 수 없 다. 이 소켓이라는 것은 처음에 BSD(Berkeley Software Distribution) UNIX에서 처음 소개된 개념인데, 그래서 버클리 소켓이라고 부르기도 한다. 유닉스에서 소켓 프로그래밍을 할때의 소켓이 바로 버클리 소켓이다. MS-Windows에서는 Winsock이라는 이름으로 소켓을 지원한다. 버클리 소켓과 정확히 똑같지는 않지만 개념이라든가 함수 호출법 등에 있어서 거의 비슷하다.
1.4 기타 용어들
1 API라는 것은 어떤 OS 등이 사용자에게 제공하는 함수 집합을 말한다. 또는 DBMS같은 것도 DBMS에 접근할 수 있는 함수 집합을 제공하는데 이런 것도 API라고 한다. 여기서 말하는 API는 소켓이 응용 프로그램에게 제공하는 함수 집합을 말한다.
▪ IP주소라는 것은 4바이트의 숫자로 구성된 주소를 말한다. 예를 들면 211.96.5.10과 같은 식 의 주소가 IP 주소이다. 전 세계적으로 공인 IP 주소는 유일하다. 정확히 말하자면 IP 주소는 NIC(Network Interface Card)와 1:1로 대응된다. 이 말은 쉽게 말하면 랜카드가 한장 꽂혀 있는 컴퓨터는 IP 주소를 하나를 갖지만 랜카드가 두장 꽂혀 있는 컴퓨터라면 IP 주소를 2개 가 질 수도 있다는 것이다. 간혹 컴퓨터 상식 문제 등을 보면 “IP 주소와 컴퓨터는 1:1로 대응된 다”고 나와 있고 이것이 맞는 것으로 되어 있는 경우를 보는데, 엄밀히 말하면 틀린 말이다. IP 주소는 컴퓨터와 대응하는 것이 아니라 컴퓨터 안에 꽂혀 있는 랜카드와 1:1 대응된다. 그리고 랜 카드라는 말은 범용적으로 쓰이는 말이긴 하지만 좋은 표현은 아니다. “랜카드”는 약간 용산의 분위기가 나는 용어이고, 정식으로는 Ethernet Card라고 해야 맞다. 왜냐하면 ethernet card가 꼭 랜 환경에서만 사용되는 것은 아니기 때문이다. WAN, CAN 환경 등에도 모두 Ethernet card를 사용한다. 물론 최근 WAN이니 LAN이니 하는 개념은 그다지 의미가 없다. NIC(Network Interface Card)라는 것은 이더넷 카드보다 약간 더 포괄적인 의미로서 네트워크 연결을 할 수 있는 카드들을 통칭해서 NIC이라고 한다.
▪ 도메인 주소란 yahoo.com과 같이 우리가 알아보기 쉬운 글자로 되어 있는 컴퓨터의 주소를 말 한다. 이러한 도메인 주소는 오로지 사람을 위한 것일뿐, 컴퓨터는 이러한 주소를 모두 IP 주소로 변환해서 처리한다. 이러한 변환을 처리해 주는 것이 바로 DNS(Domain Name Service)이다.
▪ FQDN이라는 표현도 가끔 나오는데, 이것은 Fully Qualified Domain Name(완전하게 표기 된 도메인 이름)을 말한다. 일반적으로 yahoo.com이라는 도메인 앞에 www를 붙여서 쓰는데, www는 yahoo.com 도메인에 속한 컴퓨터의 이름이다. 컴퓨터의 이름은 어떤 것이라도 될 수 있 다. mail.yahoo.com도 될수 있고, ftp.yahoo.com도 될 수 있다. 이렇게 컴퓨터의 이름과 도메 인 이름을 합쳐서 표기하는 것을 FQDN이라고 한다. 그러나 많은 경우 www.yahoo.com과 같이 단어로 쓴 주소를 그냥 “도메인”이라고 부르는 경우가 많다. 그러나 엄밀히 얘기하면 도메인 주 소는 yahoo.com까지만이고 www.yahoo.com은 FQDN 표기법이다.
▪ 포트(port)라는 것은 하나의 컴퓨터에서 실행되고 있는 많은 네트웍 프로그램을 구분하기 위해 부여된 번호이다. 잘 알려진(well-known) 응용 계층 프로토콜은 특정 포트 번호가 할당되어 있 다. 예를 들면 FTP는 21번, TELNET은 23번, SMTP는 25번, DNS는 53번, HTTP는 80번, POP3는 110번, IMAP은 143번 등이다. 우리가 야후 사이트에 접속할때에는 주소만 입력하고 포트 번호는 명시하지 않지만, 웹브라우저에 의해 자동적으로 80번 포트로 접속을 하는 것이다. 이 번호는 1부터 65535까지 사용할 수 있으나, 1024번까지는 잘 알려진(well-known) 포트 번 호이므로 사용하지 않는 것이 좋다. 특히 유닉스 계열에서는 1024번 이하의 포트 번호를 사용하 려면 특권이 있어야 하는 경우도 있다. 0번 포트는 예약된 포트 번호로서 사용되지 않는다.
1.5 다중화(multiplexing)와 역다중화(demultiplexing)
서버에는 각기 다른 포트 번호를 사용하는 여러 서버 프로그램이 실행중일 수 있고, 클라이언트는 이 서버 프로그램에 동시에 접속하여 서비스를 받는 상황을 가정할 수 있다. 편의를 위해 클라이언 트 측의 프로세스를 각각 C1, C2라고 하고, 서버측의 프로세스를 S1, S2라고 하자. C1은 S1 프 로세스에 접속하여 서비스를 받는 중이고, C2는 S2 프로세스에 접속하여 서비스를 받는다고 가정 하자. 이 경우에 클라이언트에서 서버로 수많은 패킷이 그냥 보내지게 되는데, 서버측에서는 이 패 킷들을 S1 프로세스에게 전달해야할지 S2에게 전달해야할지 판단을 해야한다. 이때 사용하는 정 보가 IP 주소와 포트 번호의 쌍이다. IP 주소 하나만으로는 서버로의 패킷 전달은 할수 있지만, 서버로 전달된 패킷을 적절한 응용 프로그램에 전달할 수는 없다. 이때 포트 번호를 같이 사용하는 것이다. 용어를 정의하면 다중화란 응용 계층의 데이터를 네트워크 계층으로 넘겨주는 것을 말하며, 역다중 화란 전송 계층의 데이터를 올바른 응용 프로그램에 전달하는 것을 말한다. 다중화와 역다중화의 목적은 패킷을 올바른 응용 프로그램에게 전달하기 위함이고, 이를 위해 IP 주소와 포트 번호를 사용한다.
2. 응용 계층 프로토콜의 예: HTTP
실제 프로그램 개발에 들어가기에 앞서 간단한 응용 계층 프로토콜에 대해 알아보기로 하자. 이후 에 개발할 프로그램도 HTTP를 이용한 것이 될 것이므로 HTTP에 대해 간단히 소개를 한다. 응 용 계층 프로토콜을 연구해 보는 것은 추후에 여러분이 직접 독자적인 프로토콜을 설계할 때에도 많은 참고가 될 것이다. 특히 HTTP는 비교적 간단하기 때문에 처음 연구하기에도 적당하다. HTTP는 Hyper Text Transfer Protocol로서 응용 계층에서 작동하는 프로토콜이다. 용도는 잘 알다시피 웹에서 URL(Uniform Resource Location)을 이용하여 HTML 파일이나 그림 파 일 등을 전송할 수 있는 프로토콜이다. 이 HTTP는 현재 버전 1.0과 1.1이 있는데, 현재의 웹브 라우저나 웹서버는 모두 버전 1.1을 지원한다. HTTP 버전 1.1은 버전 1.0과 하위 호환성이 있 기 때문에 결국은 현재의 웹브라우저나 웹서버는 버전 1.0과 1.1을 모두 지원하는 셈이다. 물론 버전 1.1이 좀더 강력하고 효율적인 기능들이 있지만 아무래도 약간 더 복잡하다. 따라서 우리는 HTTP 1.0에 한해 알아보고, 이후에도 HTTP 1.0 규격으로 프로그램을 제작하도록 하자. 물론 HTTP 1.0에 맞추어 제작을 해도 HTTP 1.1을 지원하는 애플리케이션과 호환 가능하다. 여기서 제작해볼 프로그램 이상의 수준을 원한다면 RFC 문서를 더 참고하기 바란다.
우리가 야후 사이트에 접속하려면 웹브라우저를 열고 주소란에 http://kr.yahoo.com을 입력한 다. 그러면 웹브라우저가 야후 웹서버에 접속하여 html 문서 및 관련 그림들을 받아서 여러분의 모니터로 보여주는 것이다. 이 과정을 telnet을 이용하여 직접 해볼 수 있다. 일단 MS-Windows 환경에서 해보려면 도스창을 열고 유닉스 환경이라면 터미널 창을 하나 열도록 한다. 그리고 다음과 같이 명령을 내려본다.
[root@chaos ~]# telnet kr.yahoo.com 80 Trying 211.32.119.135… Connected to kr.yahoo.com. Escape character is ‘^]’. GET / HTTP/1.0
굵은 글자로 된 것을 입력하면 된다. 먼저 쉘 프롬프트에서 “telnet kr.yahoo.com 80”을 입 력하면 kr.yahoo.com 서버의 80번 포트로 접속을 하게 된다. 접속이 되면 “ GET / HTTP/1.0”을 입력하고 엔터를 두번 친다. 이 명령은 HTTP 프로토콜의 일부로서 웹페이지를 요청하는 명령이다. 형식은 “[method] [URL] [HTTP version] ”이다. Method는 GET, POST, HEAD 등이 올수 있으며 대문자가 원칙이다. URL은 원하는 주소의 URL을 입력하면 된 다. 그 다음에 HTTP 버전은 HTTP/1.0 혹은 HTTP/1.1이 올수 있는데, 우리는 버전 1.0을 사용할 것이므로 HTTP/1.0을 사용하면 된다. 이렇게 입력하고 엔터를 두번치면 야후 웹서버로 부터 html 문서가 전송됨을 볼 수 있을 것이다. 이것이 가장 간단한 HTTP 1.0의 사용 예이다. 우리가 제일 먼저 만들 프로그램은 방금 telnet으로 해본 것을 자동화하는 프로그램에 지나지 않 는다.
3. CAsyncSocket과 CSocket
이 글을 읽는 여러분은 최소한 MFC 프로그래밍에 대한 기초는 있다고 가정한다. 따라서 MFC에 대한 기본 개념이라든가 MFC를 이용하여 간단한 프로그램 만드는 법 등은 따로 설명하지 않는다. MS-Windows 환경에서 소켓 프로그래밍을 하는 방법은 크게 2가지로 나눌 수 있다. Win32 API를 직접 이용하는 방법과 MFC 1에서 제공하는 클래스를 이용하는 방법이다. 여기서는 MFC를 이용하여 소켓 프로그래밍을 해 볼 것이다. MFC에서 소켓 프로그래밍을 위해 제공되는 클래스는 2개가 있는데 CAsyncSocket과 CSocket 이다. CAsyncSocket 클래스는 CObject에서 바로 상속된 클래스로서 비동기 소켓을 지원한다. 그리고 CSocket은 다시 CAsyncSocket에서 상속된 클래스로서 동기 소켓이며, CArchive를 이용한 입출력도 가능하다. 비동기 소켓이란 데이터 송/수신 함수를 호출했을 때 호출을 하자마자 바로 리턴이 되는 것을 말하고, 동기 소켓이란 데이터 송/수신 함수를 호출했을 때 함수가 끝날때 까지 반환하지 않는 것을 말한다. CAsyncSocket과 CSocket 중 어떤 것을 사용할 것인지를 작 업의 성격과 개인의 프로그래밍 스타일 등에 영향을 받을 수 있겠는데, 여기서는 CSocket을 이용 하여 프로그래밍을 해 보겠다. CSocket이 아무래도 CAsyncSocket보다 더 추상화된 클래스이 기 때문에 우리가 실제로 사용하기엔 좀더 편하다.
1 MFC란 Microsoft Foundation Class로서 MS사에서 제공하는 클래스 모음이다. 마치 자바에서 제공되는 클래스 집합과 비슷한 것이다.
4. 첫 소켓 프로그래밍: HTTP Client 초기버전
일단 필자의 개발환경은 MS-Windows 2000 Professional 및 XP Professional에 Visual Studio C++.NET이다. Visual Studio 6.0을 써도 아무 상관은 없으나, 기본적으로 클래스 상속 방법과 같은 기본적인 통합개발환경의 사용법은 알고 있어야 할 것이다.
여기서는 네트워크 프로그램을 처음 만들어보는 사람을 위해 가장 간단한 클라이언트 프로그램을 만들어 볼 것이다. 만약 이 정도 수준은 간단히만들 수 있다면이장은 그냥 건너뛰어도 좋다.
여기서 만들어 볼 프로그램은 HTTP 프로토콜을 준수하여 웹에 위치한 특정 파일을 받아오는 클 라이언트 프로그램을 만들 것이다. 즉 우리가 만들어볼 프로그램은 웹 상에 위치하는 그림이라든가 html 파일, mp3 파일 등을 HTTP 프로토콜을 이용하여 전송받아 파일로 저장해주는 프로그램 이다.
Visual Studio를 실행하여 MFC 응용 프로그램을 만들되 Dialog based(대화상자 기반)로 하자. 그리고 “고급 기능”에서 “Windows 소켓”도 선택하자. Visual Studio .NET을 기준으로 설 명하지만 Visual Studio 6.0에서도 AppWizard에서 소켓 지원을 선택하는 것이 있다. 프로젝트 이름은 필자의 경우 HttpClient로 하였으나, 이것은 독자들 마음대로 해도 된다. 만약 지금 설명 하는 것도 따라하지 못하겠다면 MFC 기본을 더 공부해야 될 것이다. 이 정도가 이해가 안 된다면 더 이상 이 글을 읽어봐도 별 의미가 없을 것이다.
위와 같이 “Windows 소켓”을 선택하면 된다. 그리고 다음과 같이 컨트롤들을 위치시키자. 1개 의 버튼이 있다. 이보다 더 간단할 수가 없다.
이제 여러분이 많은 MFC 기본 서적에서 했던 것과 같이 여기에서는 “다운로드 ” 버튼의 클릭 (BN_CLICKED)에 대한 이벤트 핸들러를 만들면 된다. 다음이 이 버튼에 대한 이벤트 핸들러의 전부이다.
void CHttpClientDlg::OnBnClickedButtonDownload() { CSocket socket; socket.Create(0); socket.Connect(”kr.yahoo.com”, 80);
// 야후 서버로 “GET / HTTP/1.0″ + 엔터 두번 메세지 보내기.
char *request = “GET / HTTP/1.0₩r₩n₩r₩n”;
socket.Send(request, strlen(request));
// 야후가 보내오는 정보 받아서 저장하기.
CFile file;
file.Open(”test.html”, CFile::modeCreate | CFile::modeWrite);
int cbRcvd; char buf[4000]; while ((cbRcvd = socket.Receive(buf, 4000)) > 0) {
file.Write(buf, cbRcvd); }
file.Close();
//.
socket.Close(); }
바로 이 코드가 가장 간단한 소켓 프로그램의 형태이다. 간단하지만 작동은 잘 된다. 첫 줄부터 설 명을 하면 다음과 같다.
여기서는 가장 간단한 형태를 만들어 보는 것이 목적이므로 CSocket 클래스의 인스턴스를 “CSocket socket;”과 같이 직접 생성하여 사용하였다. 이렇게 CSocket의 인스턴스를 하나 만 든 후에, 이것을 가지고 여러 멤버 함수들을 호출하면 되는 것이다.
가장 먼저 호출한 함수가 Create() 함수이다. 이것은 소켓을 초기화해주는 함수라고 보면 된다. 원래 이 함수는 인자를 4개까지 받을 수 있는데, 보통 맨 처음인자인 포트 번호만 주면된다 1. 여 기서는 포트 번호로 0을 넘겨주었다. 형식적으로 0을 포트 번호로 넘겨주었지만 클라이언트 측의 프로그램을 만들 때는 특별히 포트 번호를 주지 않는다. 사실 포트 번호 0은 사용하지 않는 포트 번호이다. 그런데 여기서 분명히 알아야 할 사실이 있는데, 포트 번호를 주지 않았다고 해서 클라 이언트측에서 포트 번호가 사용되지 않는 것이 아니다. 다만, 운영체제가 현재 사용하지 않는 포트 번호 중 하나를 임의로 할당해 줄 뿐이다. 클라이언트 측에서 사용하는 포트 번호는 보통 알 필요 는 없다. 그냥 운영체제가 알아서 포트 번호를 할당해주면 그냥 쓰면 된다. 물론 어떤 포트 번호가 자동으로 할당되었는지 꼭 알아야겠다면 알아낼 수는 있다.
이렇게 소켓을 초기화한 이후에는 Connect 함수를 호출한다. 이 함수를 호출하면 서버측으로 접 속을 하게 되는데, 인자로는 접속할 서버의 주소와 포트 번호를 적어주면 된다. 서버의 주소는 도 메인 주소가 될 수도 있고 IP 주소가 될 수도 있다. 여기서는 “kr.yahoo.com” 서버의 80번 포
1 사실 인자를 포트 번호도 주지 않고 아무것도 넘겨주지 않아도 된다. 이 경우 포트 번호로 0을 넘긴 것과 동일하게 처리된다. Create 함수의 프로토 타입을 보면 소켓 인자를 생략했을 경우 0을 대입해 주도록 되어 있다.
트1로 접속하였다. 다시 한번 말하지만 여기서 Connect 함수에서 적어준 포트 번호는 접속할 서 버의 포트 번호이다. 클라이언트의 포트 번호는 위에서 Create 함수를 호출할 때 운영체제에 의 해 이미 임의로 할당되었다. 여기까지 했으면 야후의 웹서버에 연결이 된 상태이다. telnet으로 테 스트해본 것과 비교해보자면 “telnet kr.yahoo.com 80”이라고 치고 엔터를 친 상태까지 구현된 것이다.
텔넷으로 야후 서버에 접속한 뒤에 웹페이지를 얻어오기 위해 “GET / HTTP/1.0[엔터 2번] ”과 같은 메시지를 보내준 것을 기억할 것이다. 이제 그 메시지를 야후 웹서버로 보내줄 차례이다. 그 것은 Send 함수를 이용하면 된다. Send 함수는 보낼 데이터와 그 데이터의 크기를 인자로 받는다. 보낼 데이터와 크기를 별도로 받 는 이유는 바이너리 데이터 전송을 위해서이다. Send 함수의 원형을 잘 살펴보면 알겠지만 첫번째 인자인 데이터는 void *형이다. 이 말은 문자열 뿐만 아니라 어떤 바이너리 형태라도 모두 보낼 수 있다는 뜻이다. 그래서 널 종료(null terminated) 문자열이 아닌 데이터도 제대로 전송하기 위해 별도로 데이터의 크기도 인자로 받는 것이다. 그리고 엔터에 해당하는 기호로 “\r\n”을 사용 했는데 이것은 CR+LF 2 기호이다. 보통 많은 경우에 \n 기호만 사용해도 엔터로 인식이 되는데, 네트워크 프로그래밍을 할 때에는 명시적으로 \r\n을 모두 써 주기를 권장한다. 이기종간의 호환 성을 극대화하기 위한 목적이다.
이제 서버로 문서 요청을 했으므로 서버는 요청한 문서를 클라이언트에게 보내줄 것이다. 그러면 클라이언트 측에서 Receive 함수를 호출하여 서버가 보내주는 데이터를 받으면 된다. Receive 함수를 while loop 안에서 호출하면서 전송받는 대로 족족 파일에 기록을 했다. Receive 함수는 데이터를 받을 버퍼와 한번에 받을 수 있는 최대 크기를 인자로 받는다. 여기서는 char형의 버퍼 4000바이트를 마련해서 그곳에 데이터를 받았다. 그리고 Receive 함수는 실제로 받은 데이터수 를 반환한다. 우리는 최대 4000바이트까지 받으라고 했지만 항상 4000바이트를 모두 다 받는 것 은 아니다. 그래서 항상 몇 바이트를 받았는지 체크를 해서 그 바이트만큼만 파일에 기록한 것이다. 그리고 특별한 경우로 이 함수가 0을 리턴한다면 접속이 끊긴 것이다. 위에서 보듯이 while loop의 조건으로 ((cbRcvd = socket.Receive(buf, 4000)) > 0)을 검사 하는데, 이 의미는 서버로의 접속이 끊길때까지 계속 받으라는 의미이다. HTTP/1.0은 그 특성상 요청한 데이터가 모두 전송되면 서버측에서 접속을 끊어버리게 되어 있다. 여기서 한가지 의문점이 생기는 독자도 있을 것 같다. 만약 네트웍 접속이 매우 느려서 while loop의 조건문 안에서 Receive 함수를 호출하고 다음번 Receive 함수를 호출할때까지 1바이트 도 수신되지 않았다면 어떻게 될 것인가 하고 말이다. 전송받을 것은 더 있지만 접속이 워낙 느려 서 아직 전송받은 데이터가 없을 경우 말이다. 이 경우에 Receive 함수가 0을 반환할까? 방금
1 우리가 만들고 있는 프로그램이 HTTP 클라이언트를 구현하는 프로그램이기 때문에 당연히 HTTP가 사용하는 포트 번호인 80번 포트로 접속을 해야 한다. 2 Carriage Return + Line Feed
얘기했지만 이 함수의 반환값 0은 접속이 끊겼을 경우에만이다. 정답은 “블록(block) ”된다는 것 이다. 쉽게 말하면 1바이트라도 서버가 보내줄때까지 Receive 함수는 반환을 하지 않는다는 것이 다. 즉 Receive 함수에서 그냥 멈춰있는 것이다. 이것을 블록된다고 하고 동기화 소켓의 특징이 다. 앞에서도 얘기했지만 우리가 지금 사용하고 있는 CSocket은 동기화 소켓이다. 따라서 이러한 문제점이 생기는 것이다. 그러나 인터넷 접속 환경이 좋은 경우 이런 것은 별 문제가 되지 않는다. 아마 여러분도 대개 인터넷 접속 환경이 양호하기 때문에 위의 코드는 아무런 문제도 일으키지 않 을 것이고, 전혀 막힌다거나 멈춘다는 느낌은 받기 어려울 것이다. 그러나 어떠한 환경에서도 견고 (robust)하고 안정적으로 작동하는 프로그램을 만들기 위해서는 프로그램이 계속 멈춰 있어서는 안되며 특별한 처리, 즉 타임아웃 처리를 해줘야 한다. 이것에 대해서는 다음에 다시 알아볼 것이 다.
이렇게 모두 전송을 받은 후에는 “socket.Close();”와 같이 Close 함수를 호출하여 소켓을 닫으 면 된다. 물론 파일도 다 사용했으니 파일도 닫아주었다.
지금까지 모두 잘 따라했다면 여러분은 이제 소켓 프로그래밍을 할 수 있는 것이다. 필자는 항상 이런 초간단한 코드로 설명된 책이 거의 없다는 것이 항상 안타까웠다. 서점에 가보면 Visual C++에 대해 논하는 많은 책에서 소켓 프로그래밍에 대한 예제를 제시하면서 터무니없이 복잡한 코드를 제시하는 경우가 많다. 심지어 채팅 예제와 같은 것을 그냥 바로 제시해버리는 경우도 있는 데 초보자가 그것을 따라하기란 정말 어렵지 않나 생각된다. 필자는 무언가를 설명할 때에는 핵심 만을 나타내고 나머지는 모두 생략해 버려서 설명하고자 하는 것만을 최대로 부각시키고 최대한 간단히 설명하는 것을 좋아한다. 응용은 여러분의 몫이지 필자가 해줄 수 있는 것은 아니다.
이제 위의 프로그램을 컴파일해서 실행해보면 야후 웹서버로 접속해서 초기 웹페이지를 받아온 후 에 그것을 test.html 파일로 저장할 것이다. test.html 파일을 웹브라우저로 열어보면 다음과 같 이 보일 것이다.
야후 첫 페이지가 맞긴 맞는데 위에 뭔가 이상한 코드들이 있는 것을 볼수있다. 프로그램이 잘못 된 것이 아니라 HTTP 헤더가 붙어있는 것 뿐이다. 우리가 프로그램에서 특별히 HTTP 헤더와 본문(body) 부분을 나누지 않고 모두 저장했기 때문에 이렇게 된 것이다. 이 test.html 파일을 텍스트 에디터로 열어보면 HTML 코드가 시작되기 전에 다음과 비슷한 헤더가 보일 것이다.
HTTP/1.1 200 OK Date: Thu, 09 May 2002 01:07:20 GMT Cache-Control: private Connection: close Content-Type: text/html
이것이 웹서버가 클라이언트에게 보내주는 HTTP 헤더이고, 실제 본문(body)는 헤더 다음에 공백 한줄이 위치한 다음에 이어진다. 실제로 telnet을 이용하여 야후의 웹서버로 접속해서 테스트해봐도 잘 보면 위와 같은 헤더가 먼저 보내진 이후에 본문이 보내지는 것을 볼 수 있을 것이다. 헤더와 본문을 구분하는 기준은 맨 처음 나오는 공백 줄(기호로는 “\r\n\r\n”)이다.
여기까지 알아본 것만 가지고도 여러분은 인터넷 게시판에서 이메일을 긁어오는 프로그램이라든가 인터넷에 올려져 있는 MP3 파일(물론 실시간 방송을 말하는 것은 아니다.) 등을 받는 프로그램을 만들 수 있을 것이다. 물론 견고한 프로그램, 즉 중간에 멈춰버리거나 하지 않는 프로그램을
만들기 위해서는 추가적인 코드가 필요하다.
마지막으로 소켓 생성(Create 함수)과 서버 접속시(Connect 함수) 위의 코드는 에러 처리를 하나도 하지 않았는데, 실제로는 다음과 같이 에러 처리를 해 줘야 좋을 것이다. 물론 이 정도는 독자 여러분이 알아서 할 것이라 생각한다. 참고로 Create는 성공했고 Connect만 실패할 경우에 Close 함수를 호출해서 소켓을 닫아주어야 한다.
// 소켓 생성.
if (!socket.Create(0)) { MessageBox(”소켓 생성에 실패했습니다.”, “에러”, MB_OK | MB_ICONEXCLAMATION); return;
}
// 접속 시도
if (!socket.Connect(”kr.yahoo.com”, 80)) { socket.Close(); MessageBox(”서버 접속에 실패했습니다.”, “에러”, MB_OK | MB_ICONEXCLAMATION); return;
}
5. 타임아웃 기능 구현
이번에는 타임아웃 처리가 된 클라이언트 프로그램을 만들어 보도록 하겠다. 이 장을 제대로 이해 하기 위해서는 C++의 클래스 상속 및 재정의(overriding)에 대한 이해가 필수적이다. 이것은 C++의 기초에 해당하는 것이므로 여기서는 별도로 설명하지 않겠다.
이전에 만들어본 프로그램은 정말로 가장 간단한 형태를 구현했기 때문에 CSocket의 인스턴스를 직접 생성해서 사용했지만, 타임 아웃 처리 등을 하기 위해서는 그렇게 해서는 안된다. CSocket 클래스를 상속받아 새로운 클래스를 만들고 이 클래스의 몇가지 함수를 overriding(재정의)해야 한다. 그리고 이 새로운 클래스의 인스턴스를 생성시켜서 사용해야 한다.
CSocket 클래스는 전에도 얘기했지만 동기화 소켓이고 Connect, Send, Receive 함수 등을 호 출했을 때 바로 처리가 되지 않으면 멈춰진 상태(블록된 상태)로 있게 된다. 그래서 CSocket에는 블록된 상태에서 어떤 메시지가 발생하면 이를 감지하고 OnMessagePending이라는 함수를 호 출해 주는 기능이 있다. 그런데 우리가 이 OnMessagePending 함수 내에서 어떤 처리를 해주기 위해서는 이 함수를 overriding해야 하고, 그러기 위해서는 CSocket 클래스를 상속받아 자신만 의 클래스를 새로 만들어야 하는 것이다.
함수를 호출했을 때 블록될 수 있는 함수는 Connect, Send, Receive 정도이다. 먼저 어떤 경우
에 타임 아웃이 필요한지 실제로 예를 들어 보여주도록 하겠다.
만약 야후 서버로 접속하되 실수로 1000번 포트로 접속을 시도했다고 가정하자. 지금 바로 도스 창이나 터미널창을 열어서 “telnet kr.yahoo.com 1000”을 입력하고 엔터를 쳐보자. “Trying 211.32.119.135…”이라고 나오더니 아무리 시간이 지나도 아무런 응답이 없을 것이다 1. Ctrl-C 를 눌러 그냥 종료하도록 하자. 물론 1~2분 정도가 지나면 텔넷 프로그램이 타임아웃을 걸어서 종료될 수도 있지만 그것은 텔넷 프로그램에서 접속을 포기하고 타임아웃 처리를 한 것이다. 실제 우리가 만든 프로그램에서는 뭔가가 잘못되었을 때 영영 블록된 상태에서 빠져나올 수가 없다. 예 를 들면 Connect 함수를 호출하여 서버로 접속을 시도했는데, 성공도 실패도 아닌 아무런 응답도 없는 경우에 블록될 수 있다. 또한 서버는 아무런 데이터도 보내줄 생각을 안하는데 클라이언트에 서 Receive 함수를 호출한 경우에도 블록된다. Send 함수 호출 역시 블록될 수 있다. 정말 무한 정 대기 상태에 빠지게 된다.
이런 경우에 대한 해결책으로 타임아웃을 처리하는 예제를 만들어 보기로 한다. 우리가 제일 먼저 만들어볼 프로그램은 야후에 1000번 포트로 접속하되 5초 동안 Connect 함수가 반환되지 않는 다면 강제로 종료시켜 버리는 것이다. 방금 테스트해보아서 알겠지만 야후 웹서버로 1000번 포트 에 접속하면 아무런 응답이 없이 블록될 것이다. 이 자리를 빌어 좋은 테스트 환경을 제공해준 야 후 코리아 측에도 감사를 표하는 바이다.
Visual C++을 이용해서 새로운 프로젝트를 만들되 Dialog based(대화상자 기반)으로 하고 역시 버튼 하나만 위치시키도록 하자. 필자는 프로젝트 이름을 TimeOut이라고 하였다. 혼동을 피하기 위해 되도록이면 필자와 같은 이름을 사용하도록 하자. 물론 처음 프로젝트를 생성할 때 “Windows 소켓 ”에 체크하는 것을 잊지 말자. 다음과 같이 각자 재량에 따라 만들면 되겠다.
역시 정말 간단한 프로그램이다. “접속 시도 ” 버튼을 누르면 야후 웹서버의 1000번 포트로 접속을 시도하고 5초가 지나면 타임아웃이 되는 프로그램이다. 그런데 실제 코드를 작성하기전에 해야할 일이 있는데 CSocket 클래스를 상속하여 새로운 소켓을 하나 만드는 일이다. 필자는 CSocket을
1 야후에 1000번 포트로 접속했을 때 아무런 응답이 없는 이유는 야후 측의 방화벽 설정 때문이다. 야후 웹서버의 80번 포트를 제외한 포트는 보안을 위해 모두 접속을 막아버렸고, 이로 인해 아무런 응답이 없다. 방화벽 설정은 보통 거부와 무시가 있는데 이 경우는 무시이다. 더 자세한 것은 방화벽 관련 문서(iptables 등)를 참조하기 바란다.
상속한 클래스를 CDataSocket이라는 이름으로 만들었다. 그러면 CDataSocket 클래스의 선언 및 구현 파일인 DataSocket.h와 DataSocket.cpp 파일이 생겼을 것이다.
우리가 CSocket 클래스를 상속한 이유는 OnMessagePending 함수를 overriding하기 위해서 이다. 그 함수를 다음과 같이 재정의하자.
BOOL CDataSocket::OnMessagePending() { MSG Message; if (::PeekMessage(&Message, NULL, WM_TIMER, WM_TIMER, PM_NOREMOVE)) {
if (Message.wParam == 10) { ::PeekMessage(&Message, NULL, WM_TIMER, WM_TIMER, PM_REMOVE); CancelBlockingCall(); Close();
} }
return CSocket::OnMessagePending(); }
만약 여러분이 Win32 API 프로그래밍에 익숙하다면 위의 코드를 아무런 부담없이 이해할 것이지만 MFC로만 프로그래밍하던 분이라면 이해하기 좀 힘들지도 모르겠다. 만약 위의 코드가 잘 이해가 되지 않는다면 김상형씨의 “윈도우 API 정복” 책의 윈도우, 메시지 파트 부분을 참고하기 바란다. 참 잘 나와있는 책이다. 한마디 덧붙이자면 아무리 MFC로 프로그래밍을 하더라도 Win32 API를 제대로 모르고서는 프로그래밍을 잘 할 수가 없다. 방금 소개한 김상형씨의 “윈도우 API 정복 ”은 초/중급 수준에서 필자가 추천하는 책이다. Win32 API를 처음 공부해보겠다면 그 책을 보는 것도 괜찮을 것이다.
OnMessagePending 함수는 Connect, Send, Receive 등의 함수 호출로 블록된 도중에 어떤 메시지가 발생하면 자동으로 호출되는 함수이다. 이것은 CSocket 클래스에 의해 제공되는 기능이다. OnMessagePending 함수 안에 overriding한 코드를 간략하게 설명해 보자면 PeekMessage(&Message, NULL, WM_TIMER, WM_TIMER, PM_NOREMOVE)1 함수는 현재 메시지 큐에 WM_TIMER 이벤트가 있는지 확인하는 것이다. 확인만 하고 WM_TIMER 메시지는 메시지 큐에 그대로 남겨둔다. 만약 WM_TIMER 메시지가 메시지 큐에 있다면 다시 이 메시지의 WPARAM값이 10인지 확인한다. WM_TIMER가 발생했을 때 WPARAM에는
1 첫번째 인자는 메시지 구조체를 입력받을 변수의 포인터, 두번째 인자는 메시지를 받을 윈도우의 핸 들인데 NULL을 지정하면 현재 쓰레드의 모든 메시지를 대상으로 한다. 그리고 세번째와 네번째는 어 떤 메시지를 체크할 것인지에 대한 일종의 범위를 지정한 것이다. WM_TIMER부터 WM_TIMER까 지로 정의했으니 이것은 WM_TIMER 메시지만 정확하게 체크하라는 것이다. 마지막 PM_NOREMOVE는 메시지 확인만 하고 메시지 큐에서 메시지를 지우지는 말라는 의미이다. 자세한 것은 MSDN을 참고하기 바란다.
타이머의 ID가 들어간다. 즉 타이머의 ID가 10번인지 확인하는 것이다. 만약 타이머의 ID가 10이 맞으면 메시지 큐에서 WM_TIMER 메시지를 삭제하고 CancelBlockingCall과 Close 함수를 호출한다. CancelBlockingCall은 현재 블록된 상태로 있는 함수를 취소하는 함수이고 Close 함수는 소켓을 닫아버리는 것이다. 즉 타임아웃이 되면 블록된 함수를 취소하고 접속을 끊어버리는 것이다.
이렇게 OnMessagePending 함수를 재정의한 후에 실제로 타임아웃을 이용한 프로그램을 작성해 보도록 하자. 우선 TimeOutDlg.cpp(혹은 여러분의 다이얼로그 클래스 구현 파일)에 #include “DataSocket.h”를 써줘야 한다. CSocket의 인스턴스를 생성할 것이 아니라 CSocket을 상속받은 CDataSocket의 인스턴스를 만들 것이기 때문에 이 클래스의 선언 파일을 포함시켜줘야 한다. 이제 “접속 시도 ” 버튼에 대한 이벤트 핸들러를 만들고 다음과 같이 코드를 입력한다.
void CTimeOutDlg::OnBnClickedButtonConnect() { CDataSocket socket;
if (!socket.Create(0)) {
MessageBox(”소켓 생성에 실패했습니다.”, “에러”, MB_OK | MB_ICONWARNING);
return;
}
SetTimer(10, 5000, NULL);
if (!socket.Connect(”kr.yahoo.com”, 1000)) {
KillTimer(10);
socket.Close();
MessageBox(”타임 아웃되었습니다.”, “에러”, MB_OK | MB_ICONWARNING);
return;
}
KillTimer(10);
MessageBox(”서버에 접속되었습니다.”, “알림”, MB_OK);
socket.Close(); }
위의 코드가 타임아웃을 구현한 간단한 예이다. 여기서는 Connect 함수가 블록된다는 가정하에 Connect 함수가 5초동안 반환하지 않으면 강제로 Connect 함수 호출을 취소하도록 한 것이다. 소켓을 생성(Create)하는 부분은 이전과 다를 것이 없다. 주의해서 볼 부분은 Connect 함수를 호출하는 전후이다. 즉 SetTimer 호출 부분부터 if 문 다음의 KillTimer 부분까지가 핵심이다.
두가지 시나리오를 생각해 보자. 우선 Connect 함수가 성공적으로 호출되어 지체없이 TRUE를 반환하는 경우를 생각해 보자. 이 경우 SetTimer 함수로 ID 10번, 시간 간격 5초의 타이머를 설치하고, 바로 Connect 함수 를 호출하게 된다. 그리고 이 Connect 함수가 TRUE를 반환하기 때문에 if 문 안의 코드는 실
행되지 않고 바로 if 문 다음으로 가서 ID 10번의 타이머를 파괴하고는 계속 다음의 작업을 계속 할 것이다. 이런 경우 Connect 함수가 5초 안에 충분히 끝날 것이므로 WM_TIMER 메시지는 발생하지 않는다 1. 이번엔 Connect 함수를 호출했을 때 블록된다는 시나리오를 생각해 보자. 일단 SetTimer 함수 로 타이머 ID 10번, 시간 간격 5초 2의 타이머를 생성했다. 시간 간격을 5초로 했기 때문에 5초 뒤에 WM_TIMER 메시지가 발생할 것이다. 그리고 Connect 함수를 호출하였는데, 이 부분에서 블록된 상태로 있을 것이다. 그래서 5초가 지나도 Connect 함수가 반환하지 않고 블록되어 있기 때문에 5초 뒤에 WM_TIMER 메시지가 발생하게 된다. 블록된 상태에서 이렇게 메시지가 발생하 면 OnMessagePending 함수가 바로 호출되게 된다. 그래서 그 메시지 안에서 WM_TIMER 메 시지가 발생했고 ID가 10번임을 확인하고는 CancelBlockingCall을 호출하여 Connect 함수를 취소하고 Close 함수를 호출하여 소켓을 닫아버리게 되는 것이다. CancelBlockingCall을 호출 하면 블록되어 있던 Connect 함수는 FALSE를 반환하게 된다. 참고로 Send나 Receive 함수 의 경우엔 CancelBlockingCall을 호출하면 SOCKET_ERROR를 반환하게 된다. 어쨌든 이런 방식으로 동기화 소켓에서의 타임아웃을 처리하는 것이다.
Connect 함수에 대한 타임아웃만 예로 보였지만 Send, Receive 함수 모두 같은 방식으로 처리 하면 된다.
위의 프로그램을 컴파일해서 실행해 보면 다음과 같이 버튼을 누른뒤 5초 뒤에 타임아웃이 되는 것을 볼 수 있을 것이다.
참고로 버튼을 누르고 나서 바로 윈도우를 이동시키려 하면 움직여지지 않을 것이다. 이것은 Connect 함수에서 블록되어 있기 때문이다. 소켓을 통해 데이터 송수신을 바쁘게 하는 도중에도
1 SetTimer 함수로 타이머를 설치했을 때 첫 WM_TIMER 메시지는 함수를 처음 호출했을 때가 아 니라, SetTimer를 호출하고 지정한 시간이 지난 다음에 발생한다. 타이머는 한번 설치해놓으면 지정 한 시간 간격으로 계속 WM_TIMER 메시지를 발생시키므로 사용했으면 바로 KillTimer 함수로 파 괴해주어야 한다. 2 SetTimer의 2번째 인자는 WM_TIMER 메시지가 발생할 시간 간격인데 단위가 ms(밀리 세컨드, 1000분의 1초)이다. 따라서 5초 간격의 WM_TIMER 메시지를 발생시키려면 5000을 넘겨주면 된 다. 참고로 윈도우 95/98/ME에서 최소 시간 간격은 55ms이고, 윈도우 NT/2k/XP에서는 10ms이 다. 시간 간격에 한계가 있음을 참고로 알아두자.
윈도우 이동이라든가 최대/최소화 등이 부드럽게 되도록 하려면 데이터 송수신 작업을 쓰레드로 만들어야 한다. 이것은 나중에 다시 알아보도록 한다.
March 11, 2008 — Digital Angel Master
Socket Programming with MFC in Win32 Environment
2002년 5월 9일 윤성진(chaos@ChaosClub.net) http://ChaosClub.net
서론
네트웍 프로그래밍이라는 것은 단순히 데이터를 처리하는 수준의 application과는 좀 다른 면이 있다. 어쩌면 어느 정도 시스템 프로그래밍이라고 할 수 있다. 이 말은 그 네트웍 프로그램이 실행될 환경, 즉 운영 체제를 이해해야 할 뿐만 아니라, TCP/IP에 대한 기본적인 지식이 필요하다는 뜻이다. 필자 역시 여러 언어로 네트웍 프로그래밍을 해봤는데, 역시 네트웍 프로그래밍은 쉽지만은 않다. 기 본적으로 알아야 할 것도 많고 실제 프로그래밍을 할 때에 생각해야 할 것도 많다. 네트웍 프로그램은 한대의 컴퓨터에서만 실행되는 것이 아니라, 최소한 2대 이상의 컴퓨터에서 상호 작용을 통해 작동하 고, 컴퓨터 외적인 요소에도 영향을 많이 받는다. 예를 들면 네트웍 회선이 불량하다든가, 잘못된 방화 벽 설정, 라우터 설정 등으로 인한 문제, RFC 문서 1를 준수하지 않는 상대측 네트웍 프로그램과의 호 환성 문제 등 실제로 프로그래밍을 하다보면 외적인 요소에 많은 영향을 받게된다. 따라서 필자 역시 그러한 부분에서 많은 어려움을 겪었고 네트웍 프로그래밍에 입문하는 다른 분들에 게 도움이 되고자 이 글을 남기기로 결심했다. 만약 이 글을 읽는 여러분이 인터넷 사용에 익숙하고 유닉스/리눅스 등을 어느 정도 다룬다면 이 글을 보는 데에 많은 도움이 될 것이다. 물론 이 글은 MS-Windows 환경에서의 네트웍 프로그래밍을 다 루지만 리눅스 등을 많이 사용해 보았다면 IP 주소라든가 DNS, 포트 번호 등과 같은 단어에 익숙할 것이다. 이 글을 읽는 독자의 수준은 C/C++ 중급 이상의 수준, 그리고 기본적인 네트웍 상식과 MFC를 이용 한 간단한 프로그램 작성 수준이면 된다. 너무 높은 수준을 요구한다고 생각되면 미리 공부를 좀 더 해볼 필요가 있겠다.
필자는 현재 대전 대덕연구단지에 위치한 모 벤처기업의 구석 자리에 앉아서 이 글을 쓰고 있다. 병역 특례 자리를 마련해준 서울의 박사장님, 그리고 여기 대전 벤처기업의 변사장님께 이렇게 여유있는 시 간을 주심에 대해 감사드리는 바이다.
2002년 5월 8일 윤성진 (chaos@chaosclub.net)
1 RFC 문서란 Request For Comments란 뜻인데, 인터넷과 관련된 여러 규약을 정리한 문서라고 생각하면 된다. 쉽게 말해 인터넷에서 사용되는 많은 프로토콜들의 표준을 정의한 문서이다. 이 문서 는 http://www.ietf.org에 가면 검색이 가능하다. 대중적인 프로토콜을 이용한 프로그램을 개발할 때에는 꼭 RFC 문서를 참조하여 표준을 지켜야 한다.
1. 네트워크 기초 및 TCP/IP
여기서는 간단한 TCP/IP 이론에 대해 소개한다. 꼭 필요한 수준만 논하고 있으니 더 깊은 이해 를 원하는 사람은 기타 서적을 참고하기 바란다.
1.1 서버(server)와 클라이언트(client) “클라이언트”와 “서버”라는 말이 자주 나오게 될텐데, 초보자를 위해 설명하자면 다음과 같다. 서버나 클라이언트는 모두 네트워크에 연결된 컴퓨터를 가리킨다. 다만 네트워크에 묶여져 있을 때 그 컴퓨터가 하는 “역할”에 따라 서버와 클라이언트를 구분한다. 서버(server)는 하인이라는 뜻 의 영어 단어인데, 어떤 작업 요청을 받아들여서 그것을 처리한 뒤에, 결과를 되돌려주는 일을 하 는 컴퓨터를 말한다. 클라이언트는 이러한 서버에게 작업을 의뢰하고 결과를 받아가는 역할을 하는 컴퓨터를 말한다. 어떤 책에서는 “세션을 초기화하는 쪽 ”을 클라이언트라고 정의한다. 이것도 좀 난해하긴 하지만 그럴듯한 정의이다. 일반적으로 서버용 컴퓨터는 클라이언트보다 성능이 상당히 뛰어나지만 꼭 그런 것만은 아니다. 그 역할에 따라 서버와 클라이언트를 구분한다는 것을 명심하자. 예를 들어 보면 우리가 야후 사이트 에 접속해서 웹페이지를 받아오는 것도 서버와 클라이언트의 관계가 성립되는데, 우리의 컴퓨터가 야후에 접속해서 웹페이지를 “요청”하므로 우리의 컴퓨터는 클라이언트가 되고 야후의 컴퓨터가 서버가 되는 것이다. 이런 경우에 야후 서버는 웹 서비스를 제공하므로 특별히 “웹서버”라는 말 도 사용한다. 요즘 특이한 경우로 PtoP라는 것이 있다. 이것은 개인용 컴퓨터끼리 직접 연결해서 서로 자료를 주고 받는다든가 하는 것을 말하는데, 구루구루나 소리바다 같은 것이 PtoP(Peer to Peer)이다. 이런 방식의 경우에는 각 컴퓨터가 서버의 역할과 클라이언트의 역할을 동시에 한다. 꼭 이런 것이 아니더라도 우리의 컴퓨터가 서버의 역할과 클라이언트의 역할을 동시에 할 수 있다. 예를 들면 우리가 윈도우 2000 컴퓨터에서 FTP를 열어놓고 친구에게 자료를 받아가게 해놓으면 서 동시에 그 컴퓨터를 이용해서 웹서핑을 할수도 있는데, FTP 서비스면에서 보면 서버의 역할을, 웹서핑 관점에서 보면 클라이언트의 역할을 하는 것이다. 참고로 서버와 클라이언트 외에 “호스트”라는 말도 네트워크 서적에서 많이 나오는데, 이 말은 네트워크의 말단에 연결된 컴퓨터를 말하는 것이다. 따라서 일반적으로 서버나 클라이언트들은 모 두 호스트이다. 그러나 네트워크 중심(core)에 위치한 스위치라든가 라우터와 같은 장비들은 호스 트가 아니다.
1.2 프로토콜 스택 일단 프로토콜이라는 말에 대해 정의해 보면 다음과 같다. 프로토콜이란 “네트워크 소프트웨어의 핵심으로 둘 이상의 통신 개체 사이에 교환되는 메시지의 형태, 의미, 전송순서, 그리고 메시지 송 수신 및 기타 사건에 수행할 동작을 정의한 규약”이다. 흔히 전송 규약이라고 많이 해석을 하는데, 서버와 클라이언트가 서로 메시지를 어떻게 주고 받으며, 또 특정한 메시지를 받았을때에는 어떤
동작을 취해야 하는지를 모두 정의해 놓은 것을 전송 규약이라고 한다. 프로토콜에는 아래에서 설 명할 TCP, UDP, IP와 같이 인터넷 프로토콜 스택을 구성하는 것도 있지만 응용 계층에서 설계 되고 동작하는 응용 계층 프로토콜들도 있다. 응용 계층 프로토콜에 해당하는 것이 HTTP, FTP, SMTP, POP3, IMAP 등이다. 이러한 응용 계층 프로토콜은 여러분도 얼마든지 독자적으로 설계 할 수 있다. 인터넷에서 사용되는 프로토콜은 TCP/IP라고 한다. 이것은 인터넷에서 사용되는 프로토콜인 TCP와 IP를 함께 부르는 말이다. 이 인터넷 프로토콜은 다섯 계층으로 분리되는데, 하부에서부터 물리(physical), 링크(link), 네트워크(network), 전송(transport), 응용(application) 계층으 로 나뉜다. 어떤 자료에는 물리 계층과 링크 계층을 합쳐서 그냥 링크 계층이라고 하기도 한다. 이 런 경우에는 4 계층이 된다. 이렇게 인터넷 프로토콜은 계층 구조를 이루는데, 이런 계층 구조를 프로토콜 스택이라고 한다. 인터넷 프로토콜 스택을 상위에서부터 살펴보면 다음과 같다.
▪ 응용 계층 (application layer) 응용 계층이란 우리가 흔히 사용하는 네트웍 프로그램들을 뜻한다. 여기에는 인터넷 익스플로러, 알FTP, 구루구루 등이 해당한다. 우리가 만드는 프로그램도 바로 이 응용 계층에 해당한다.
▪ 트랜스포트 계층 (transport layer) 트랜스포트 계층은 응용 계층에서 넘겨준 데이터를 다시 하위 계층인 네트워크 계층에 넘겨준다. 이 계층에는 2가지 프로토콜이 있는데, TCP(Transmission Control Protocol)와 UDP(User Datagram Protocol)이 있다. TCP는 연결지향형(connection-oriented) 서비스라고 하는데, 이것은 목적지로의 데이터 전달 보장과 흐름제어(flow control) 기능을 제공한다. 그리고 UDP는 비연결형(connectionless) 서비스로서 데이터가 목적지에 반드시 전달된다거나 순서대로 전달된 다는 보장을 하지 않는다. 일반적으로 TCP를 많이 사용한다. 우리가 제작할 프로그램도 TCP를 사용한다. UDP는 매우 한 정적으로 사용되는데 대표적으로 온라인 음악 방송이나 동화상 서비스 등이 UDP를 사용한다. UDP는 데이터의 오류도 체크하지 않고 패킷의 순서조차 보장하지 않음에도 장점이 한가지 있는 데 속도가 TCP에 비해 빠르다는 것이다. 그래서 패킷 1 몇 개가 없어져도 큰 상관이 없는 실시간 방송 등에는 UDP가 사용되는 것이다. 이런 특수한 경우를 제외하면 모두 TCP를 사용한다고 보 면 된다. 우리가 TCP를 이용하여 네트웍 프로그램을 만들면 우리가 전송하는 데이터는 틀림없이 수신측에 도착하며 패킷의 순서도 보낸 순서와 동일함을 보장받을 수 있다. 이 알고리즘은 놀라울 정도로 정 교하고 치밀하게 설계되어 있다.
1 패킷(packet)이란 네트워크를 통해 데이터를 전달할 때 일정한 크기의 묶음으로 전달을 하게 되는 데 이것을 패킷이라고 한다. 패킷에는 헤더와 데이터 부분이 있어서 헤더에는 패킷의 송신지 정보, 수 신지 정보 등이 담겨지게 된다.
▪ 네트워크 계층 (network layer) 네트워크 계층 프로토콜은 2가지 요소를 갖는다. 우선 IP 데이터그램의 필드를 정의하고 종단 시 스템(end system)과 라우터가 이들 필드에 어떻게 동작하는지를 정의하는 프로토콜을 가지는데, 이것이 IP(Internet Protocol)이다. 또한 소스와 목적지 사이에 데이터그램이 가야하는 경로를 결정하는 라우팅 프로토콜을 수행한다. 이 계층을 흔히 IP 계층이라고도 한다.
▪ 링크 계층 (link layer) 링크 계층은 패킷을 실제로 다음 노드로 전송해주는 계층이다. 예를 들면 랜과 같은 이더넷 (Ethernet)같은 것이 있다.
▪ 물리 계층 간혹 인터넷 프로토콜 스택을 4계층으로 구분할 때 이 물리 계층은 링크 계층에 포함되기도 한다. 이것은 데이터 통신의 가장 밑바닥으로서 동축케이블이라든가, 광케이블과 같은 물리적인 계층에서 비트 수준의 데이터 전송을 뜻한다.
링크 계층과 물리 계층은 다분히 하드웨어적인 분야로서 우리가 어떻게 다룰 수 있는 것은 아니다. 실제로 우리가 다룰 수 있는 것은 응용 계층 뿐이며, 특별히 드라이버 개발자라든가 운영체제 개발 자에 한해 전송 계층이나 네트워크 계층은 접근할 수가 있을 것이다.
1.3 소켓(socket) 우리가 흔히 네트워크 프로그래밍을 한다는 것을 소켓 프로그래밍이라고 얘기를 한다. 소켓이란 응 용 계층과 전송 계층 사이의 API(Application Programming Interface) 1이다. 즉 우리가 응 용 프로그램을 개발할 때 에러 검출이라든가 패킷의 순서를 맞춘다든가 하는 일을 우리가 하는 것 이 아니라, 소켓을 사용하여 전송 계층으로 넘겨주기만 하면 그 다음부터는 알아서 흐름 제어라든 가 에러 검출 및 재전송 등이 자동으로 이루어지게 된다. 물론 정확한 순서로 틀림없이 데이터가 전송된다는 것은 TCP를 사용할 때의 이야기이다. UDP를 사용하면 그런 서비스를 이용할 수 없 다. 이 소켓이라는 것은 처음에 BSD(Berkeley Software Distribution) UNIX에서 처음 소개된 개념인데, 그래서 버클리 소켓이라고 부르기도 한다. 유닉스에서 소켓 프로그래밍을 할때의 소켓이 바로 버클리 소켓이다. MS-Windows에서는 Winsock이라는 이름으로 소켓을 지원한다. 버클리 소켓과 정확히 똑같지는 않지만 개념이라든가 함수 호출법 등에 있어서 거의 비슷하다.
1.4 기타 용어들
1 API라는 것은 어떤 OS 등이 사용자에게 제공하는 함수 집합을 말한다. 또는 DBMS같은 것도 DBMS에 접근할 수 있는 함수 집합을 제공하는데 이런 것도 API라고 한다. 여기서 말하는 API는 소켓이 응용 프로그램에게 제공하는 함수 집합을 말한다.
▪ IP주소라는 것은 4바이트의 숫자로 구성된 주소를 말한다. 예를 들면 211.96.5.10과 같은 식 의 주소가 IP 주소이다. 전 세계적으로 공인 IP 주소는 유일하다. 정확히 말하자면 IP 주소는 NIC(Network Interface Card)와 1:1로 대응된다. 이 말은 쉽게 말하면 랜카드가 한장 꽂혀 있는 컴퓨터는 IP 주소를 하나를 갖지만 랜카드가 두장 꽂혀 있는 컴퓨터라면 IP 주소를 2개 가 질 수도 있다는 것이다. 간혹 컴퓨터 상식 문제 등을 보면 “IP 주소와 컴퓨터는 1:1로 대응된 다”고 나와 있고 이것이 맞는 것으로 되어 있는 경우를 보는데, 엄밀히 말하면 틀린 말이다. IP 주소는 컴퓨터와 대응하는 것이 아니라 컴퓨터 안에 꽂혀 있는 랜카드와 1:1 대응된다. 그리고 랜 카드라는 말은 범용적으로 쓰이는 말이긴 하지만 좋은 표현은 아니다. “랜카드”는 약간 용산의 분위기가 나는 용어이고, 정식으로는 Ethernet Card라고 해야 맞다. 왜냐하면 ethernet card가 꼭 랜 환경에서만 사용되는 것은 아니기 때문이다. WAN, CAN 환경 등에도 모두 Ethernet card를 사용한다. 물론 최근 WAN이니 LAN이니 하는 개념은 그다지 의미가 없다. NIC(Network Interface Card)라는 것은 이더넷 카드보다 약간 더 포괄적인 의미로서 네트워크 연결을 할 수 있는 카드들을 통칭해서 NIC이라고 한다.
▪ 도메인 주소란 yahoo.com과 같이 우리가 알아보기 쉬운 글자로 되어 있는 컴퓨터의 주소를 말 한다. 이러한 도메인 주소는 오로지 사람을 위한 것일뿐, 컴퓨터는 이러한 주소를 모두 IP 주소로 변환해서 처리한다. 이러한 변환을 처리해 주는 것이 바로 DNS(Domain Name Service)이다.
▪ FQDN이라는 표현도 가끔 나오는데, 이것은 Fully Qualified Domain Name(완전하게 표기 된 도메인 이름)을 말한다. 일반적으로 yahoo.com이라는 도메인 앞에 www를 붙여서 쓰는데, www는 yahoo.com 도메인에 속한 컴퓨터의 이름이다. 컴퓨터의 이름은 어떤 것이라도 될 수 있 다. mail.yahoo.com도 될수 있고, ftp.yahoo.com도 될 수 있다. 이렇게 컴퓨터의 이름과 도메 인 이름을 합쳐서 표기하는 것을 FQDN이라고 한다. 그러나 많은 경우 www.yahoo.com과 같이 단어로 쓴 주소를 그냥 “도메인”이라고 부르는 경우가 많다. 그러나 엄밀히 얘기하면 도메인 주 소는 yahoo.com까지만이고 www.yahoo.com은 FQDN 표기법이다.
▪ 포트(port)라는 것은 하나의 컴퓨터에서 실행되고 있는 많은 네트웍 프로그램을 구분하기 위해 부여된 번호이다. 잘 알려진(well-known) 응용 계층 프로토콜은 특정 포트 번호가 할당되어 있 다. 예를 들면 FTP는 21번, TELNET은 23번, SMTP는 25번, DNS는 53번, HTTP는 80번, POP3는 110번, IMAP은 143번 등이다. 우리가 야후 사이트에 접속할때에는 주소만 입력하고 포트 번호는 명시하지 않지만, 웹브라우저에 의해 자동적으로 80번 포트로 접속을 하는 것이다. 이 번호는 1부터 65535까지 사용할 수 있으나, 1024번까지는 잘 알려진(well-known) 포트 번 호이므로 사용하지 않는 것이 좋다. 특히 유닉스 계열에서는 1024번 이하의 포트 번호를 사용하 려면 특권이 있어야 하는 경우도 있다. 0번 포트는 예약된 포트 번호로서 사용되지 않는다.
1.5 다중화(multiplexing)와 역다중화(demultiplexing)
서버에는 각기 다른 포트 번호를 사용하는 여러 서버 프로그램이 실행중일 수 있고, 클라이언트는 이 서버 프로그램에 동시에 접속하여 서비스를 받는 상황을 가정할 수 있다. 편의를 위해 클라이언 트 측의 프로세스를 각각 C1, C2라고 하고, 서버측의 프로세스를 S1, S2라고 하자. C1은 S1 프 로세스에 접속하여 서비스를 받는 중이고, C2는 S2 프로세스에 접속하여 서비스를 받는다고 가정 하자. 이 경우에 클라이언트에서 서버로 수많은 패킷이 그냥 보내지게 되는데, 서버측에서는 이 패 킷들을 S1 프로세스에게 전달해야할지 S2에게 전달해야할지 판단을 해야한다. 이때 사용하는 정 보가 IP 주소와 포트 번호의 쌍이다. IP 주소 하나만으로는 서버로의 패킷 전달은 할수 있지만, 서버로 전달된 패킷을 적절한 응용 프로그램에 전달할 수는 없다. 이때 포트 번호를 같이 사용하는 것이다. 용어를 정의하면 다중화란 응용 계층의 데이터를 네트워크 계층으로 넘겨주는 것을 말하며, 역다중 화란 전송 계층의 데이터를 올바른 응용 프로그램에 전달하는 것을 말한다. 다중화와 역다중화의 목적은 패킷을 올바른 응용 프로그램에게 전달하기 위함이고, 이를 위해 IP 주소와 포트 번호를 사용한다.
2. 응용 계층 프로토콜의 예: HTTP
실제 프로그램 개발에 들어가기에 앞서 간단한 응용 계층 프로토콜에 대해 알아보기로 하자. 이후 에 개발할 프로그램도 HTTP를 이용한 것이 될 것이므로 HTTP에 대해 간단히 소개를 한다. 응 용 계층 프로토콜을 연구해 보는 것은 추후에 여러분이 직접 독자적인 프로토콜을 설계할 때에도 많은 참고가 될 것이다. 특히 HTTP는 비교적 간단하기 때문에 처음 연구하기에도 적당하다. HTTP는 Hyper Text Transfer Protocol로서 응용 계층에서 작동하는 프로토콜이다. 용도는 잘 알다시피 웹에서 URL(Uniform Resource Location)을 이용하여 HTML 파일이나 그림 파 일 등을 전송할 수 있는 프로토콜이다. 이 HTTP는 현재 버전 1.0과 1.1이 있는데, 현재의 웹브 라우저나 웹서버는 모두 버전 1.1을 지원한다. HTTP 버전 1.1은 버전 1.0과 하위 호환성이 있 기 때문에 결국은 현재의 웹브라우저나 웹서버는 버전 1.0과 1.1을 모두 지원하는 셈이다. 물론 버전 1.1이 좀더 강력하고 효율적인 기능들이 있지만 아무래도 약간 더 복잡하다. 따라서 우리는 HTTP 1.0에 한해 알아보고, 이후에도 HTTP 1.0 규격으로 프로그램을 제작하도록 하자. 물론 HTTP 1.0에 맞추어 제작을 해도 HTTP 1.1을 지원하는 애플리케이션과 호환 가능하다. 여기서 제작해볼 프로그램 이상의 수준을 원한다면 RFC 문서를 더 참고하기 바란다.
우리가 야후 사이트에 접속하려면 웹브라우저를 열고 주소란에 http://kr.yahoo.com을 입력한 다. 그러면 웹브라우저가 야후 웹서버에 접속하여 html 문서 및 관련 그림들을 받아서 여러분의 모니터로 보여주는 것이다. 이 과정을 telnet을 이용하여 직접 해볼 수 있다. 일단 MS-Windows 환경에서 해보려면 도스창을 열고 유닉스 환경이라면 터미널 창을 하나 열도록 한다. 그리고 다음과 같이 명령을 내려본다.
[root@chaos ~]# telnet kr.yahoo.com 80 Trying 211.32.119.135… Connected to kr.yahoo.com. Escape character is ‘^]’. GET / HTTP/1.0
굵은 글자로 된 것을 입력하면 된다. 먼저 쉘 프롬프트에서 “telnet kr.yahoo.com 80”을 입 력하면 kr.yahoo.com 서버의 80번 포트로 접속을 하게 된다. 접속이 되면 “ GET / HTTP/1.0”을 입력하고 엔터를 두번 친다. 이 명령은 HTTP 프로토콜의 일부로서 웹페이지를 요청하는 명령이다. 형식은 “[method] [URL] [HTTP version] ”이다. Method는 GET, POST, HEAD 등이 올수 있으며 대문자가 원칙이다. URL은 원하는 주소의 URL을 입력하면 된 다. 그 다음에 HTTP 버전은 HTTP/1.0 혹은 HTTP/1.1이 올수 있는데, 우리는 버전 1.0을 사용할 것이므로 HTTP/1.0을 사용하면 된다. 이렇게 입력하고 엔터를 두번치면 야후 웹서버로 부터 html 문서가 전송됨을 볼 수 있을 것이다. 이것이 가장 간단한 HTTP 1.0의 사용 예이다. 우리가 제일 먼저 만들 프로그램은 방금 telnet으로 해본 것을 자동화하는 프로그램에 지나지 않 는다.
3. CAsyncSocket과 CSocket
이 글을 읽는 여러분은 최소한 MFC 프로그래밍에 대한 기초는 있다고 가정한다. 따라서 MFC에 대한 기본 개념이라든가 MFC를 이용하여 간단한 프로그램 만드는 법 등은 따로 설명하지 않는다. MS-Windows 환경에서 소켓 프로그래밍을 하는 방법은 크게 2가지로 나눌 수 있다. Win32 API를 직접 이용하는 방법과 MFC 1에서 제공하는 클래스를 이용하는 방법이다. 여기서는 MFC를 이용하여 소켓 프로그래밍을 해 볼 것이다. MFC에서 소켓 프로그래밍을 위해 제공되는 클래스는 2개가 있는데 CAsyncSocket과 CSocket 이다. CAsyncSocket 클래스는 CObject에서 바로 상속된 클래스로서 비동기 소켓을 지원한다. 그리고 CSocket은 다시 CAsyncSocket에서 상속된 클래스로서 동기 소켓이며, CArchive를 이용한 입출력도 가능하다. 비동기 소켓이란 데이터 송/수신 함수를 호출했을 때 호출을 하자마자 바로 리턴이 되는 것을 말하고, 동기 소켓이란 데이터 송/수신 함수를 호출했을 때 함수가 끝날때 까지 반환하지 않는 것을 말한다. CAsyncSocket과 CSocket 중 어떤 것을 사용할 것인지를 작 업의 성격과 개인의 프로그래밍 스타일 등에 영향을 받을 수 있겠는데, 여기서는 CSocket을 이용 하여 프로그래밍을 해 보겠다. CSocket이 아무래도 CAsyncSocket보다 더 추상화된 클래스이 기 때문에 우리가 실제로 사용하기엔 좀더 편하다.
1 MFC란 Microsoft Foundation Class로서 MS사에서 제공하는 클래스 모음이다. 마치 자바에서 제공되는 클래스 집합과 비슷한 것이다.
4. 첫 소켓 프로그래밍: HTTP Client 초기버전
일단 필자의 개발환경은 MS-Windows 2000 Professional 및 XP Professional에 Visual Studio C++.NET이다. Visual Studio 6.0을 써도 아무 상관은 없으나, 기본적으로 클래스 상속 방법과 같은 기본적인 통합개발환경의 사용법은 알고 있어야 할 것이다.
여기서는 네트워크 프로그램을 처음 만들어보는 사람을 위해 가장 간단한 클라이언트 프로그램을 만들어 볼 것이다. 만약 이 정도 수준은 간단히만들 수 있다면이장은 그냥 건너뛰어도 좋다.
여기서 만들어 볼 프로그램은 HTTP 프로토콜을 준수하여 웹에 위치한 특정 파일을 받아오는 클 라이언트 프로그램을 만들 것이다. 즉 우리가 만들어볼 프로그램은 웹 상에 위치하는 그림이라든가 html 파일, mp3 파일 등을 HTTP 프로토콜을 이용하여 전송받아 파일로 저장해주는 프로그램 이다.
Visual Studio를 실행하여 MFC 응용 프로그램을 만들되 Dialog based(대화상자 기반)로 하자. 그리고 “고급 기능”에서 “Windows 소켓”도 선택하자. Visual Studio .NET을 기준으로 설 명하지만 Visual Studio 6.0에서도 AppWizard에서 소켓 지원을 선택하는 것이 있다. 프로젝트 이름은 필자의 경우 HttpClient로 하였으나, 이것은 독자들 마음대로 해도 된다. 만약 지금 설명 하는 것도 따라하지 못하겠다면 MFC 기본을 더 공부해야 될 것이다. 이 정도가 이해가 안 된다면 더 이상 이 글을 읽어봐도 별 의미가 없을 것이다.
위와 같이 “Windows 소켓”을 선택하면 된다. 그리고 다음과 같이 컨트롤들을 위치시키자. 1개 의 버튼이 있다. 이보다 더 간단할 수가 없다.
이제 여러분이 많은 MFC 기본 서적에서 했던 것과 같이 여기에서는 “다운로드 ” 버튼의 클릭 (BN_CLICKED)에 대한 이벤트 핸들러를 만들면 된다. 다음이 이 버튼에 대한 이벤트 핸들러의 전부이다.
void CHttpClientDlg::OnBnClickedButtonDownload() { CSocket socket; socket.Create(0); socket.Connect(”kr.yahoo.com”, 80);
// 야후 서버로 “GET / HTTP/1.0″ + 엔터 두번 메세지 보내기.
char *request = “GET / HTTP/1.0₩r₩n₩r₩n”;
socket.Send(request, strlen(request));
// 야후가 보내오는 정보 받아서 저장하기.
CFile file;
file.Open(”test.html”, CFile::modeCreate | CFile::modeWrite);
int cbRcvd; char buf[4000]; while ((cbRcvd = socket.Receive(buf, 4000)) > 0) {
file.Write(buf, cbRcvd); }
file.Close();
//.
socket.Close(); }
바로 이 코드가 가장 간단한 소켓 프로그램의 형태이다. 간단하지만 작동은 잘 된다. 첫 줄부터 설 명을 하면 다음과 같다.
여기서는 가장 간단한 형태를 만들어 보는 것이 목적이므로 CSocket 클래스의 인스턴스를 “CSocket socket;”과 같이 직접 생성하여 사용하였다. 이렇게 CSocket의 인스턴스를 하나 만 든 후에, 이것을 가지고 여러 멤버 함수들을 호출하면 되는 것이다.
가장 먼저 호출한 함수가 Create() 함수이다. 이것은 소켓을 초기화해주는 함수라고 보면 된다. 원래 이 함수는 인자를 4개까지 받을 수 있는데, 보통 맨 처음인자인 포트 번호만 주면된다 1. 여 기서는 포트 번호로 0을 넘겨주었다. 형식적으로 0을 포트 번호로 넘겨주었지만 클라이언트 측의 프로그램을 만들 때는 특별히 포트 번호를 주지 않는다. 사실 포트 번호 0은 사용하지 않는 포트 번호이다. 그런데 여기서 분명히 알아야 할 사실이 있는데, 포트 번호를 주지 않았다고 해서 클라 이언트측에서 포트 번호가 사용되지 않는 것이 아니다. 다만, 운영체제가 현재 사용하지 않는 포트 번호 중 하나를 임의로 할당해 줄 뿐이다. 클라이언트 측에서 사용하는 포트 번호는 보통 알 필요 는 없다. 그냥 운영체제가 알아서 포트 번호를 할당해주면 그냥 쓰면 된다. 물론 어떤 포트 번호가 자동으로 할당되었는지 꼭 알아야겠다면 알아낼 수는 있다.
이렇게 소켓을 초기화한 이후에는 Connect 함수를 호출한다. 이 함수를 호출하면 서버측으로 접 속을 하게 되는데, 인자로는 접속할 서버의 주소와 포트 번호를 적어주면 된다. 서버의 주소는 도 메인 주소가 될 수도 있고 IP 주소가 될 수도 있다. 여기서는 “kr.yahoo.com” 서버의 80번 포
1 사실 인자를 포트 번호도 주지 않고 아무것도 넘겨주지 않아도 된다. 이 경우 포트 번호로 0을 넘긴 것과 동일하게 처리된다. Create 함수의 프로토 타입을 보면 소켓 인자를 생략했을 경우 0을 대입해 주도록 되어 있다.
트1로 접속하였다. 다시 한번 말하지만 여기서 Connect 함수에서 적어준 포트 번호는 접속할 서 버의 포트 번호이다. 클라이언트의 포트 번호는 위에서 Create 함수를 호출할 때 운영체제에 의 해 이미 임의로 할당되었다. 여기까지 했으면 야후의 웹서버에 연결이 된 상태이다. telnet으로 테 스트해본 것과 비교해보자면 “telnet kr.yahoo.com 80”이라고 치고 엔터를 친 상태까지 구현된 것이다.
텔넷으로 야후 서버에 접속한 뒤에 웹페이지를 얻어오기 위해 “GET / HTTP/1.0[엔터 2번] ”과 같은 메시지를 보내준 것을 기억할 것이다. 이제 그 메시지를 야후 웹서버로 보내줄 차례이다. 그 것은 Send 함수를 이용하면 된다. Send 함수는 보낼 데이터와 그 데이터의 크기를 인자로 받는다. 보낼 데이터와 크기를 별도로 받 는 이유는 바이너리 데이터 전송을 위해서이다. Send 함수의 원형을 잘 살펴보면 알겠지만 첫번째 인자인 데이터는 void *형이다. 이 말은 문자열 뿐만 아니라 어떤 바이너리 형태라도 모두 보낼 수 있다는 뜻이다. 그래서 널 종료(null terminated) 문자열이 아닌 데이터도 제대로 전송하기 위해 별도로 데이터의 크기도 인자로 받는 것이다. 그리고 엔터에 해당하는 기호로 “\r\n”을 사용 했는데 이것은 CR+LF 2 기호이다. 보통 많은 경우에 \n 기호만 사용해도 엔터로 인식이 되는데, 네트워크 프로그래밍을 할 때에는 명시적으로 \r\n을 모두 써 주기를 권장한다. 이기종간의 호환 성을 극대화하기 위한 목적이다.
이제 서버로 문서 요청을 했으므로 서버는 요청한 문서를 클라이언트에게 보내줄 것이다. 그러면 클라이언트 측에서 Receive 함수를 호출하여 서버가 보내주는 데이터를 받으면 된다. Receive 함수를 while loop 안에서 호출하면서 전송받는 대로 족족 파일에 기록을 했다. Receive 함수는 데이터를 받을 버퍼와 한번에 받을 수 있는 최대 크기를 인자로 받는다. 여기서는 char형의 버퍼 4000바이트를 마련해서 그곳에 데이터를 받았다. 그리고 Receive 함수는 실제로 받은 데이터수 를 반환한다. 우리는 최대 4000바이트까지 받으라고 했지만 항상 4000바이트를 모두 다 받는 것 은 아니다. 그래서 항상 몇 바이트를 받았는지 체크를 해서 그 바이트만큼만 파일에 기록한 것이다. 그리고 특별한 경우로 이 함수가 0을 리턴한다면 접속이 끊긴 것이다. 위에서 보듯이 while loop의 조건으로 ((cbRcvd = socket.Receive(buf, 4000)) > 0)을 검사 하는데, 이 의미는 서버로의 접속이 끊길때까지 계속 받으라는 의미이다. HTTP/1.0은 그 특성상 요청한 데이터가 모두 전송되면 서버측에서 접속을 끊어버리게 되어 있다. 여기서 한가지 의문점이 생기는 독자도 있을 것 같다. 만약 네트웍 접속이 매우 느려서 while loop의 조건문 안에서 Receive 함수를 호출하고 다음번 Receive 함수를 호출할때까지 1바이트 도 수신되지 않았다면 어떻게 될 것인가 하고 말이다. 전송받을 것은 더 있지만 접속이 워낙 느려 서 아직 전송받은 데이터가 없을 경우 말이다. 이 경우에 Receive 함수가 0을 반환할까? 방금
1 우리가 만들고 있는 프로그램이 HTTP 클라이언트를 구현하는 프로그램이기 때문에 당연히 HTTP가 사용하는 포트 번호인 80번 포트로 접속을 해야 한다. 2 Carriage Return + Line Feed
얘기했지만 이 함수의 반환값 0은 접속이 끊겼을 경우에만이다. 정답은 “블록(block) ”된다는 것 이다. 쉽게 말하면 1바이트라도 서버가 보내줄때까지 Receive 함수는 반환을 하지 않는다는 것이 다. 즉 Receive 함수에서 그냥 멈춰있는 것이다. 이것을 블록된다고 하고 동기화 소켓의 특징이 다. 앞에서도 얘기했지만 우리가 지금 사용하고 있는 CSocket은 동기화 소켓이다. 따라서 이러한 문제점이 생기는 것이다. 그러나 인터넷 접속 환경이 좋은 경우 이런 것은 별 문제가 되지 않는다. 아마 여러분도 대개 인터넷 접속 환경이 양호하기 때문에 위의 코드는 아무런 문제도 일으키지 않 을 것이고, 전혀 막힌다거나 멈춘다는 느낌은 받기 어려울 것이다. 그러나 어떠한 환경에서도 견고 (robust)하고 안정적으로 작동하는 프로그램을 만들기 위해서는 프로그램이 계속 멈춰 있어서는 안되며 특별한 처리, 즉 타임아웃 처리를 해줘야 한다. 이것에 대해서는 다음에 다시 알아볼 것이 다.
이렇게 모두 전송을 받은 후에는 “socket.Close();”와 같이 Close 함수를 호출하여 소켓을 닫으 면 된다. 물론 파일도 다 사용했으니 파일도 닫아주었다.
지금까지 모두 잘 따라했다면 여러분은 이제 소켓 프로그래밍을 할 수 있는 것이다. 필자는 항상 이런 초간단한 코드로 설명된 책이 거의 없다는 것이 항상 안타까웠다. 서점에 가보면 Visual C++에 대해 논하는 많은 책에서 소켓 프로그래밍에 대한 예제를 제시하면서 터무니없이 복잡한 코드를 제시하는 경우가 많다. 심지어 채팅 예제와 같은 것을 그냥 바로 제시해버리는 경우도 있는 데 초보자가 그것을 따라하기란 정말 어렵지 않나 생각된다. 필자는 무언가를 설명할 때에는 핵심 만을 나타내고 나머지는 모두 생략해 버려서 설명하고자 하는 것만을 최대로 부각시키고 최대한 간단히 설명하는 것을 좋아한다. 응용은 여러분의 몫이지 필자가 해줄 수 있는 것은 아니다.
이제 위의 프로그램을 컴파일해서 실행해보면 야후 웹서버로 접속해서 초기 웹페이지를 받아온 후 에 그것을 test.html 파일로 저장할 것이다. test.html 파일을 웹브라우저로 열어보면 다음과 같 이 보일 것이다.
야후 첫 페이지가 맞긴 맞는데 위에 뭔가 이상한 코드들이 있는 것을 볼수있다. 프로그램이 잘못 된 것이 아니라 HTTP 헤더가 붙어있는 것 뿐이다. 우리가 프로그램에서 특별히 HTTP 헤더와 본문(body) 부분을 나누지 않고 모두 저장했기 때문에 이렇게 된 것이다. 이 test.html 파일을 텍스트 에디터로 열어보면 HTML 코드가 시작되기 전에 다음과 비슷한 헤더가 보일 것이다.
HTTP/1.1 200 OK Date: Thu, 09 May 2002 01:07:20 GMT Cache-Control: private Connection: close Content-Type: text/html
이것이 웹서버가 클라이언트에게 보내주는 HTTP 헤더이고, 실제 본문(body)는 헤더 다음에 공백 한줄이 위치한 다음에 이어진다. 실제로 telnet을 이용하여 야후의 웹서버로 접속해서 테스트해봐도 잘 보면 위와 같은 헤더가 먼저 보내진 이후에 본문이 보내지는 것을 볼 수 있을 것이다. 헤더와 본문을 구분하는 기준은 맨 처음 나오는 공백 줄(기호로는 “\r\n\r\n”)이다.
여기까지 알아본 것만 가지고도 여러분은 인터넷 게시판에서 이메일을 긁어오는 프로그램이라든가 인터넷에 올려져 있는 MP3 파일(물론 실시간 방송을 말하는 것은 아니다.) 등을 받는 프로그램을 만들 수 있을 것이다. 물론 견고한 프로그램, 즉 중간에 멈춰버리거나 하지 않는 프로그램을
만들기 위해서는 추가적인 코드가 필요하다.
마지막으로 소켓 생성(Create 함수)과 서버 접속시(Connect 함수) 위의 코드는 에러 처리를 하나도 하지 않았는데, 실제로는 다음과 같이 에러 처리를 해 줘야 좋을 것이다. 물론 이 정도는 독자 여러분이 알아서 할 것이라 생각한다. 참고로 Create는 성공했고 Connect만 실패할 경우에 Close 함수를 호출해서 소켓을 닫아주어야 한다.
// 소켓 생성.
if (!socket.Create(0)) { MessageBox(”소켓 생성에 실패했습니다.”, “에러”, MB_OK | MB_ICONEXCLAMATION); return;
}
// 접속 시도
if (!socket.Connect(”kr.yahoo.com”, 80)) { socket.Close(); MessageBox(”서버 접속에 실패했습니다.”, “에러”, MB_OK | MB_ICONEXCLAMATION); return;
}
5. 타임아웃 기능 구현
이번에는 타임아웃 처리가 된 클라이언트 프로그램을 만들어 보도록 하겠다. 이 장을 제대로 이해 하기 위해서는 C++의 클래스 상속 및 재정의(overriding)에 대한 이해가 필수적이다. 이것은 C++의 기초에 해당하는 것이므로 여기서는 별도로 설명하지 않겠다.
이전에 만들어본 프로그램은 정말로 가장 간단한 형태를 구현했기 때문에 CSocket의 인스턴스를 직접 생성해서 사용했지만, 타임 아웃 처리 등을 하기 위해서는 그렇게 해서는 안된다. CSocket 클래스를 상속받아 새로운 클래스를 만들고 이 클래스의 몇가지 함수를 overriding(재정의)해야 한다. 그리고 이 새로운 클래스의 인스턴스를 생성시켜서 사용해야 한다.
CSocket 클래스는 전에도 얘기했지만 동기화 소켓이고 Connect, Send, Receive 함수 등을 호 출했을 때 바로 처리가 되지 않으면 멈춰진 상태(블록된 상태)로 있게 된다. 그래서 CSocket에는 블록된 상태에서 어떤 메시지가 발생하면 이를 감지하고 OnMessagePending이라는 함수를 호 출해 주는 기능이 있다. 그런데 우리가 이 OnMessagePending 함수 내에서 어떤 처리를 해주기 위해서는 이 함수를 overriding해야 하고, 그러기 위해서는 CSocket 클래스를 상속받아 자신만 의 클래스를 새로 만들어야 하는 것이다.
함수를 호출했을 때 블록될 수 있는 함수는 Connect, Send, Receive 정도이다. 먼저 어떤 경우
에 타임 아웃이 필요한지 실제로 예를 들어 보여주도록 하겠다.
만약 야후 서버로 접속하되 실수로 1000번 포트로 접속을 시도했다고 가정하자. 지금 바로 도스 창이나 터미널창을 열어서 “telnet kr.yahoo.com 1000”을 입력하고 엔터를 쳐보자. “Trying 211.32.119.135…”이라고 나오더니 아무리 시간이 지나도 아무런 응답이 없을 것이다 1. Ctrl-C 를 눌러 그냥 종료하도록 하자. 물론 1~2분 정도가 지나면 텔넷 프로그램이 타임아웃을 걸어서 종료될 수도 있지만 그것은 텔넷 프로그램에서 접속을 포기하고 타임아웃 처리를 한 것이다. 실제 우리가 만든 프로그램에서는 뭔가가 잘못되었을 때 영영 블록된 상태에서 빠져나올 수가 없다. 예 를 들면 Connect 함수를 호출하여 서버로 접속을 시도했는데, 성공도 실패도 아닌 아무런 응답도 없는 경우에 블록될 수 있다. 또한 서버는 아무런 데이터도 보내줄 생각을 안하는데 클라이언트에 서 Receive 함수를 호출한 경우에도 블록된다. Send 함수 호출 역시 블록될 수 있다. 정말 무한 정 대기 상태에 빠지게 된다.
이런 경우에 대한 해결책으로 타임아웃을 처리하는 예제를 만들어 보기로 한다. 우리가 제일 먼저 만들어볼 프로그램은 야후에 1000번 포트로 접속하되 5초 동안 Connect 함수가 반환되지 않는 다면 강제로 종료시켜 버리는 것이다. 방금 테스트해보아서 알겠지만 야후 웹서버로 1000번 포트 에 접속하면 아무런 응답이 없이 블록될 것이다. 이 자리를 빌어 좋은 테스트 환경을 제공해준 야 후 코리아 측에도 감사를 표하는 바이다.
Visual C++을 이용해서 새로운 프로젝트를 만들되 Dialog based(대화상자 기반)으로 하고 역시 버튼 하나만 위치시키도록 하자. 필자는 프로젝트 이름을 TimeOut이라고 하였다. 혼동을 피하기 위해 되도록이면 필자와 같은 이름을 사용하도록 하자. 물론 처음 프로젝트를 생성할 때 “Windows 소켓 ”에 체크하는 것을 잊지 말자. 다음과 같이 각자 재량에 따라 만들면 되겠다.
역시 정말 간단한 프로그램이다. “접속 시도 ” 버튼을 누르면 야후 웹서버의 1000번 포트로 접속을 시도하고 5초가 지나면 타임아웃이 되는 프로그램이다. 그런데 실제 코드를 작성하기전에 해야할 일이 있는데 CSocket 클래스를 상속하여 새로운 소켓을 하나 만드는 일이다. 필자는 CSocket을
1 야후에 1000번 포트로 접속했을 때 아무런 응답이 없는 이유는 야후 측의 방화벽 설정 때문이다. 야후 웹서버의 80번 포트를 제외한 포트는 보안을 위해 모두 접속을 막아버렸고, 이로 인해 아무런 응답이 없다. 방화벽 설정은 보통 거부와 무시가 있는데 이 경우는 무시이다. 더 자세한 것은 방화벽 관련 문서(iptables 등)를 참조하기 바란다.
상속한 클래스를 CDataSocket이라는 이름으로 만들었다. 그러면 CDataSocket 클래스의 선언 및 구현 파일인 DataSocket.h와 DataSocket.cpp 파일이 생겼을 것이다.
우리가 CSocket 클래스를 상속한 이유는 OnMessagePending 함수를 overriding하기 위해서 이다. 그 함수를 다음과 같이 재정의하자.
BOOL CDataSocket::OnMessagePending() { MSG Message; if (::PeekMessage(&Message, NULL, WM_TIMER, WM_TIMER, PM_NOREMOVE)) {
if (Message.wParam == 10) { ::PeekMessage(&Message, NULL, WM_TIMER, WM_TIMER, PM_REMOVE); CancelBlockingCall(); Close();
} }
return CSocket::OnMessagePending(); }
만약 여러분이 Win32 API 프로그래밍에 익숙하다면 위의 코드를 아무런 부담없이 이해할 것이지만 MFC로만 프로그래밍하던 분이라면 이해하기 좀 힘들지도 모르겠다. 만약 위의 코드가 잘 이해가 되지 않는다면 김상형씨의 “윈도우 API 정복” 책의 윈도우, 메시지 파트 부분을 참고하기 바란다. 참 잘 나와있는 책이다. 한마디 덧붙이자면 아무리 MFC로 프로그래밍을 하더라도 Win32 API를 제대로 모르고서는 프로그래밍을 잘 할 수가 없다. 방금 소개한 김상형씨의 “윈도우 API 정복 ”은 초/중급 수준에서 필자가 추천하는 책이다. Win32 API를 처음 공부해보겠다면 그 책을 보는 것도 괜찮을 것이다.
OnMessagePending 함수는 Connect, Send, Receive 등의 함수 호출로 블록된 도중에 어떤 메시지가 발생하면 자동으로 호출되는 함수이다. 이것은 CSocket 클래스에 의해 제공되는 기능이다. OnMessagePending 함수 안에 overriding한 코드를 간략하게 설명해 보자면 PeekMessage(&Message, NULL, WM_TIMER, WM_TIMER, PM_NOREMOVE)1 함수는 현재 메시지 큐에 WM_TIMER 이벤트가 있는지 확인하는 것이다. 확인만 하고 WM_TIMER 메시지는 메시지 큐에 그대로 남겨둔다. 만약 WM_TIMER 메시지가 메시지 큐에 있다면 다시 이 메시지의 WPARAM값이 10인지 확인한다. WM_TIMER가 발생했을 때 WPARAM에는
1 첫번째 인자는 메시지 구조체를 입력받을 변수의 포인터, 두번째 인자는 메시지를 받을 윈도우의 핸 들인데 NULL을 지정하면 현재 쓰레드의 모든 메시지를 대상으로 한다. 그리고 세번째와 네번째는 어 떤 메시지를 체크할 것인지에 대한 일종의 범위를 지정한 것이다. WM_TIMER부터 WM_TIMER까 지로 정의했으니 이것은 WM_TIMER 메시지만 정확하게 체크하라는 것이다. 마지막 PM_NOREMOVE는 메시지 확인만 하고 메시지 큐에서 메시지를 지우지는 말라는 의미이다. 자세한 것은 MSDN을 참고하기 바란다.
타이머의 ID가 들어간다. 즉 타이머의 ID가 10번인지 확인하는 것이다. 만약 타이머의 ID가 10이 맞으면 메시지 큐에서 WM_TIMER 메시지를 삭제하고 CancelBlockingCall과 Close 함수를 호출한다. CancelBlockingCall은 현재 블록된 상태로 있는 함수를 취소하는 함수이고 Close 함수는 소켓을 닫아버리는 것이다. 즉 타임아웃이 되면 블록된 함수를 취소하고 접속을 끊어버리는 것이다.
이렇게 OnMessagePending 함수를 재정의한 후에 실제로 타임아웃을 이용한 프로그램을 작성해 보도록 하자. 우선 TimeOutDlg.cpp(혹은 여러분의 다이얼로그 클래스 구현 파일)에 #include “DataSocket.h”를 써줘야 한다. CSocket의 인스턴스를 생성할 것이 아니라 CSocket을 상속받은 CDataSocket의 인스턴스를 만들 것이기 때문에 이 클래스의 선언 파일을 포함시켜줘야 한다. 이제 “접속 시도 ” 버튼에 대한 이벤트 핸들러를 만들고 다음과 같이 코드를 입력한다.
void CTimeOutDlg::OnBnClickedButtonConnect() { CDataSocket socket;
if (!socket.Create(0)) {
MessageBox(”소켓 생성에 실패했습니다.”, “에러”, MB_OK | MB_ICONWARNING);
return;
}
SetTimer(10, 5000, NULL);
if (!socket.Connect(”kr.yahoo.com”, 1000)) {
KillTimer(10);
socket.Close();
MessageBox(”타임 아웃되었습니다.”, “에러”, MB_OK | MB_ICONWARNING);
return;
}
KillTimer(10);
MessageBox(”서버에 접속되었습니다.”, “알림”, MB_OK);
socket.Close(); }
위의 코드가 타임아웃을 구현한 간단한 예이다. 여기서는 Connect 함수가 블록된다는 가정하에 Connect 함수가 5초동안 반환하지 않으면 강제로 Connect 함수 호출을 취소하도록 한 것이다. 소켓을 생성(Create)하는 부분은 이전과 다를 것이 없다. 주의해서 볼 부분은 Connect 함수를 호출하는 전후이다. 즉 SetTimer 호출 부분부터 if 문 다음의 KillTimer 부분까지가 핵심이다.
두가지 시나리오를 생각해 보자. 우선 Connect 함수가 성공적으로 호출되어 지체없이 TRUE를 반환하는 경우를 생각해 보자. 이 경우 SetTimer 함수로 ID 10번, 시간 간격 5초의 타이머를 설치하고, 바로 Connect 함수 를 호출하게 된다. 그리고 이 Connect 함수가 TRUE를 반환하기 때문에 if 문 안의 코드는 실
행되지 않고 바로 if 문 다음으로 가서 ID 10번의 타이머를 파괴하고는 계속 다음의 작업을 계속 할 것이다. 이런 경우 Connect 함수가 5초 안에 충분히 끝날 것이므로 WM_TIMER 메시지는 발생하지 않는다 1. 이번엔 Connect 함수를 호출했을 때 블록된다는 시나리오를 생각해 보자. 일단 SetTimer 함수 로 타이머 ID 10번, 시간 간격 5초 2의 타이머를 생성했다. 시간 간격을 5초로 했기 때문에 5초 뒤에 WM_TIMER 메시지가 발생할 것이다. 그리고 Connect 함수를 호출하였는데, 이 부분에서 블록된 상태로 있을 것이다. 그래서 5초가 지나도 Connect 함수가 반환하지 않고 블록되어 있기 때문에 5초 뒤에 WM_TIMER 메시지가 발생하게 된다. 블록된 상태에서 이렇게 메시지가 발생하 면 OnMessagePending 함수가 바로 호출되게 된다. 그래서 그 메시지 안에서 WM_TIMER 메 시지가 발생했고 ID가 10번임을 확인하고는 CancelBlockingCall을 호출하여 Connect 함수를 취소하고 Close 함수를 호출하여 소켓을 닫아버리게 되는 것이다. CancelBlockingCall을 호출 하면 블록되어 있던 Connect 함수는 FALSE를 반환하게 된다. 참고로 Send나 Receive 함수 의 경우엔 CancelBlockingCall을 호출하면 SOCKET_ERROR를 반환하게 된다. 어쨌든 이런 방식으로 동기화 소켓에서의 타임아웃을 처리하는 것이다.
Connect 함수에 대한 타임아웃만 예로 보였지만 Send, Receive 함수 모두 같은 방식으로 처리 하면 된다.
위의 프로그램을 컴파일해서 실행해 보면 다음과 같이 버튼을 누른뒤 5초 뒤에 타임아웃이 되는 것을 볼 수 있을 것이다.
참고로 버튼을 누르고 나서 바로 윈도우를 이동시키려 하면 움직여지지 않을 것이다. 이것은 Connect 함수에서 블록되어 있기 때문이다. 소켓을 통해 데이터 송수신을 바쁘게 하는 도중에도
1 SetTimer 함수로 타이머를 설치했을 때 첫 WM_TIMER 메시지는 함수를 처음 호출했을 때가 아 니라, SetTimer를 호출하고 지정한 시간이 지난 다음에 발생한다. 타이머는 한번 설치해놓으면 지정 한 시간 간격으로 계속 WM_TIMER 메시지를 발생시키므로 사용했으면 바로 KillTimer 함수로 파 괴해주어야 한다. 2 SetTimer의 2번째 인자는 WM_TIMER 메시지가 발생할 시간 간격인데 단위가 ms(밀리 세컨드, 1000분의 1초)이다. 따라서 5초 간격의 WM_TIMER 메시지를 발생시키려면 5000을 넘겨주면 된 다. 참고로 윈도우 95/98/ME에서 최소 시간 간격은 55ms이고, 윈도우 NT/2k/XP에서는 10ms이 다. 시간 간격에 한계가 있음을 참고로 알아두자.
윈도우 이동이라든가 최대/최소화 등이 부드럽게 되도록 하려면 데이터 송수신 작업을 쓰레드로 만들어야 한다. 이것은 나중에 다시 알아보도록 한다.
[soket] layer
1. Socket 에 대한 기본지식
1.1. Socket Layer
Socket 은 유닉스의 파일 기술자를 통해서 다른 프로그램간의 정보교환을 가능하도록 해주는 방법으로, 같은 시스템에 있는 프로그램들간의 정보교환을 위한목적, 혹은 다른 시스템(네트웍 상으로 멀리떨어져있는) 들간의 정보교환을 위한 목적으로 사용된다.
그런데 왜 Layer 라고 부르는가 그 이유는 TCP/IP 4계층의 응용계층(applicaton layer)과 전송계층(transmission layer) 중간에 존재하기 때문이다. 아래의 그림을 보라
그림 1. 소켓 계층
위의 그림은 TCP/IP 개요에서 이미 본적이 있는 그림일 것이다. 그때의 그림과 달라진 점이 있다면, 응용계층과 전송계층에 Socket Layer 가 존재한다는 것이다. 이 Socket Layer 가 응용계층과 전송계층 사이에 존재하게 됨으로 우리 프로그래머들은 복잡하게 TCP 를 직접 제어할 필요없이, Socket Layer 에서 제공하는 다양한 함수(Socket API)를 이용해서 간단하게 인터넷 네트웍 프로그래밍 작업을 하게 되는것이다.
Socket Layer 은 응용계층에서 받은 메시지를 하부 Socket API 를 이용해서 전송계층으로 보낸다. 전송계층에는 2가지 대표적인 프로토콜 이 있는데 바로 TCP 와 UDP 이다. 그럼으로 우리 프로그래머들은 TCP프로토콜을 사용할것인지 UDP 프로토콜을 사용할것인지만 결정해주면된다.
--------------------------------------------------------------------------------
1.2. 왜 Layer 구조를 가지는가
일상 생활에서 소켓레이어와 비슷한게, 전화기라고 볼수 있을것이다. 우리는 상대편에서 전화를 걸기 위해서 상대편전화의 지리적 위치가 어디인지, 어떤 전화국에서 관리하는지, 언어를 신호로 변환 시키기 위해서 어떠한 작업을 해야하는지, 어떻게 보내야 하는지 전혀 알필요가 없다. 그냥 수화기 들고 전화 번호만 누르면 그걸로 끝이다. 즉 전화기 라는게 있음으로 그 내부에서 일어나는 여러가지 복잡한 통신 프로세스를 모르고도 상대편과 전화통화를 할수 있게 된다.
Socket Layer 이 존재함으로써, 우리는 TCP/UDP 헤더를 어떻게 만들어야 하는지, 구조가 어떻게 되는지, 어떻게 커널에 전달해야 하는지 신경쓸필요 없이 네트웍 프로그램을 만들수 있게 된다.
--------------------------------------------------------------------------------
1.3. Socket
"Socket 이라뇨 우리는 위에서 Socket Layer를 이미 다루었는데요 ?" 라고 의문을 가질수도 있을것이다. Socket Layer 과 Socket 는 엄연히 다르다. Socket Layer 는 계층을 나타내는 것이다. 즉 Socket 를 다루기 위한 계층이다. 이는 TCP가 전송계층이 아닌것과 마찬가지이다. 우리는 Socket Layer 에서 제공하는 다양한 API를 통해서 Socket 를 제어하게 된다.
그럼 Socket 이란 무엇인가. 소켓이란 유닉스 파일 지시자 를 이용하여 다른 프로그램과 정보교환을 하는 방법(혹은 도구) 이다. 일반적으로 유닉스 상에서 정보교환은 파일지시자를 통한다는걸 알고 있을것이다. 마찬가지로 Socket 를 이용한 지역 혹은 네트웍으로 연결된 프로그램 간의 정보교환 역시 파일지시자를 통해서 이루어진다.
다중연결서버 만들기(1) 의 zipcode_multi.c 를 이용해서 소켓이 어떻게 작동하는지 알아보도록 하겠다. 먼저의 위의 프로그램을 컴파일 시키고 작동을 시켜보자. 작동을 시켰다면 ps 로 zipcode_multi 프로그램의 pid 를 확인해보고 /proc/pid/fd 디렉토리로 이동해서 어떠한 파일 지시자를 가지고 있는지 확인해보도록 하자. [yundream@localhost test]# ./zipcode_multi 4444
...
[yundream@localhost test]# ps -ax | grep zipcode
2473 ttyp1 S 0:00 ./zipcode_multi 4444
pid가 2473 이므로 이 프로그램의 /proc/2473/fd 로 이동해서 ls해보면 프로그램에서 사용하고있는 파일지시자들에 대해서 알수 있다. [yundream@localhost test]# ls -al /proc/2473/fd
합계 0
dr-x------ 2 root root 0 5월 28 16:07 .
dr-xr-xr-x 3 root root 0 5월 28 16:07 ..
lrwx------ 1 root root 64 5월 28 16:14 0 -> /dev/ttyp1
lrwx------ 1 root root 64 5월 28 16:14 1 -> /dev/ttyp1
lrwx------ 1 root root 64 5월 28 16:14 2 -> /dev/ttyp1
lr-x------ 1 root root 64 5월 28 16:14 3 -> /home/mycvs/test/zipcode.txt
lrwx------ 1 root root 64 5월 28 16:14 4 -> socket:[171434]
0, 1, 2 는 각각 표준입력, 표준출력, 표준에러를 가리키는 파일지시자 라는것은 이미 알고 있을것이다. 3 은 프로그램이 연 파일을 가리킨다. 마지막 4가 바로 socket 통신을 위해 만들어진 파일 지시자이다. 다른 것들이 터미널이나 파일을 가리키는것과는 달리 socket 를 가리 키고 있음을 알수 있다.
여기에 새로운 클라이언트가 접근을하면 (telnet 이나 전용클라이언트 를 이용해서) 다음과 같은 파일 지시자가 하나 추가 될것이다. lrwx------ 1 root root 64 5월 28 16:14 5 -> socket:[171435]
--------------------------------------------------------------------------------
1.4. socket API
이번에는 socket 레이어에서 제공하는 소켓 관련 함수들을 설명하도록 하겠다.
--------------------------------------------------------------------------------
1.4.1. 소켓 생성 및 연결
1.4.1.1. socket(2) 함수
이러한 소켓 은 socket(2) 함수를 이용해서 만들어진다. 최초 socket 함수를 이용해서 소켓을 생성하면 커널은 통신을 위한 종점(end point,즉 통신연결상황을 체크하는)을 생성하고, 여기에 대한 파일 지시자를 되돌려준다. 프로그램은 socket 함수를 이용해서 생성한 파일 지시자에 새로운 연결이 들어오는 지를 확인하게 된다.
위에 있는 TCP/IP 4계층을 보면 Socket Layer 아래에는 최소한 2개 이상의 사용가능한 데이타 그램의 타입이 있음을 알수 있다. 이러한 데이타 그램의 타입에는 TCP, UDP, RAW 등이 있다. TCP 소켓, UDP 소켓, RAW 소켓이라고 부르기도 한다. 또한 다양한 소켓 주소패밀리(군)를 제공한다.
표 1. 소켓주소 패밀리
UNIX 유닉스 도메인 소켓, IPC 용으로 많이 사용한다.
INET TCP/IP 프로토콜을 이용한 인터넷주소 패밀리, 보통의 네트웍프로그래밍시 주로 사용
IPX 노벨의 IPX 프로토콜, 게임을 좋아한다면 많이 들어봤음직한
AX25 아마추어 라디오 X.25
X25 X.25 프로토콜
그러므로 socket 함수는 위의 소켓 주소 패밀리와 소켓 타입 지정이 가능해야 한다. int socket(int domain, int type, int protocol);
첫번째 아규먼트가 소켓주소 패밀리 지정을 위해서 사용되며, 두번째 아규먼트가 소켓 타입지정을 위해서 사용된다. 소켓주소 패밀리는 주로 INET(AF_INET), UNIX(AF_UNIX) 가 사용되며, 소켓타입은 TCP(SOCK_STREAM), UDP(SOCK_DGRAM), RAW(SOCK_RAW) 가 사용된다.
즉 인터넷 프로토콜을 이용하는 TCP 소켓을 만들기 원한다면 socket(AF_INET, SOCK_STREAM, 0) 과 같이 사용하면 된다.
socket 함수가 성공적으로 수행되면, 사용가능한 소켓을 가르키는 파일 지시자를 되돌려주며, 이 파일지시자는 endpoint(연결 확인 통로) 로써 사용된다.
--------------------------------------------------------------------------------
1.4.1.2. bind(2) 함수
socket 함수를 이용해서 만들어진 소켓에 이름을 부여한다.
라고 번역된 man 페이지혹은 관련된 번역서에서 설명을 하고 있지만, 소켓에 특성을 부여(소켓과 특성을 묶는다(bind))한다 라는게 좀더 적당한 설명이 아닐까 싶다. int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
인자로 주어진 sockfd 에대해서 sockaddr 을 이용해서 특성을 묶어준다. bind 함수를 통해서 우리는 sockfd 가 사용할 포트번호(port), 그리고 연결을 받아들일 IP 주소 특성등을 묶어줄수 있다. IP 주소는 IPv4, IPv6 등이 사용될수 있을것이다.
bind 함수는 보통 서버에서 사용된다. 그 이유는 대부분의 서비스(HTTP, FTP..)들이 지정된 포트번호를 통해서 서비스 되기 때문이다. 반면 클라이언트의 경우 커널에서 할당한 임의의 포트번호를 이용해서 서버와 연결하기 때문에 bind 를 사용할 필요가 없다.
--------------------------------------------------------------------------------
1.4.1.3. connect(2) 함수
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
이것은 클라이언트측 에서 사용되며, struct sockaddr 구조체에 세팅된 내용대로 서버측에 연결한다. sockaddr 구조체에는 연결될 서버에 대한 정보들, 즉 주소 패밀리 IP 번호와 PORT 번호 등이 들어가 있으며, connect 함수는 sockaddr 정보를 이용해서 서버측에 연결을 하며 서버와의 통신을 위한 endpoint 와 sockfd 를 연결시킨다. sockfd 는 socket 함수를 이용해서 만들어진 소켓 지정 번호이다.
--------------------------------------------------------------------------------
1.4.1.4. listen(2) 함수
int listen(int sockfd, int backlog);
서버측에서 사용되며 socket 함수를 이용해서 만들어진 sockfd 에 대해서, 들어 오는 연결을 기다린다. backlog 는 아직 완전히 연결되지 않은 연결들이 대기할 queue 의 길이를 명시하기 위해서 사용된다.
--------------------------------------------------------------------------------
1.4.1.5. accept(2) 함수
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
listen(2) 을 통해서 만들어진 미연결의 대기열에서 가장 앞에 있는 연결의 내용을 가져와서 새로운 연결 소켓을 만들어준다. 새로만들어진 연결소켓은 파일 지시자를 할당하여서 리턴해주게 되며, 우리는 리턴된 파일 지시자를 이용해서 새로만들어진 소켓과 통신을 할수 있게 된다.
--------------------------------------------------------------------------------
1.4.2. 입출력 함수
유닉스에서 소켓은 파일과 동일하게 취급 되기 때문에 read(), write()와같은 시스템 함수를 이용해도 대부분의 입출력을 다룰 수 있다. 그러나 이들 시스템 함수들은 네트워크의 특성을 고려하지 않고 만들었기 때문에 네트워크 정보를 필요로 하는 작업을 하기에는 적당하지 않은 점이 있다.
예를들어 UDP를 이용해서 통신을 할경우 읽기는 문제없지만 쓰기에는 문제가 생길 수 있다. UDP는 연결 소켓을 만들지 않기 때문에 쓸때 연결된 호스트의 정보를 알 수가 없기 때문에 write()함수로는 데이터를 전송할 수 없게 된다. 이럴경우에는 소켓 API를 사용해서 통신을 해주어야 한다.
--------------------------------------------------------------------------------
1.4.2.1. 입력함수 - recvfrom/recvmsg
소켓으로 부터 데이터를 받기 위해서 사용한다.
#include
#include
ssize_t recvfrom(int s, void *buf, size_t len, int flags, struct sock-
addr *from, socklen_t *fromlen);
ssize_t recvmsg(int s, struct msghdr *msg, int flags);
소켓 지정자 s로 부터 데이터를 읽는 일을 한다. 둘다 연결지향 소켓과 비연결지향 소켓 모두에 사용할 수 있다. 보통 recvfrom()함수가 사용하기에 직관적인 관계로 쉽게 사용할 수 잇다. 소켓으로 부터 len 만큼 데이터를 읽어와서 buf에 저장한다. 또한 5번째 인자인 from를 통해서 데이터를 보낸 호스트의 인터넷 정보를 얻어 올 수 있다. 그러므로 비연결 지향 소켓을 사용하더라도 이 인터넷 정보를 통해서 데이터를 수신할 목적지 호스트를 결정할 수 있게 된다. fromlen는 sockaddr 구조체의 길이다. 나머지 자세한 내용은 recvform(2)의 맨페이지를 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.2.2. 출력함수 - sendto/sendmsg
소켓으로 데이터를 보내기 위해서 사용한다. #include
#include
ssize_t sendto(int s, const void *buf, size_t len, int flags, const
struct sockaddr *to, socklen_t tolen);
ssize_t sendmsg(int s, const struct msghdr *msg, int flags);
역시 직관적인 sendto를 널리 사용한다. 소켓 지정자 s에 len크기만큼 buf의 내용을 보낸다. to를 이용해서 데이터를 받을 호스트를 명시할 수 있다. sendto()와 recvfrom()함수의 사용예는 UDP 소켓 프로그래밍 을 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.3. 인터넷 주소변환
인터넷 주소 자체가 인간이 인지하기 어려운 수로 되어 있다 보니 이것을 관리하기 쉽도록 점박이 3형제 스타일의 인터넷 주소체계를 만들어서 관리하고 여기에 또 도메인 이름을 줘서 쉽게 기억할 수 있도록 하고 있다. 프로그래머나 사용자는 보통 도메인 이름이나 점박이 3형제 스타일의 인터넷 주소를 사용하게 되는데, 실제 네트워크 프로그램에서는 32bit 주소 형태로 변환 시켜줘야할 필요가 있다.
여기에서는 이들 주소간 변환과 관련된 함수를 소개한다.
--------------------------------------------------------------------------------
1.4.4. 인터넷 주소 <-> 32bit 주소
inet_addr(3), inet_aton(3), inet_network(3), inet_ntoa(3) 의 함수를 이용해서 인터넷 주소와 32bit 주소간 변환을 할 수 있다. inet_addr(3)과 inet_network(3)함수는 점박이 3형재 스타일 인터넷 주소로 부터 32bit 주소를 얻기 위해서, inet_aton(3)과 inet_ntoa(3)그 반대의 변환 값을 얻기 위해서 사용한다. 자세한 내용은 man 페이지를 참고하기 바란다(그냥 함수 링크를 클릭하면 된다).
--------------------------------------------------------------------------------
1.4.5. 도메인 이름 -> 32bit 주소
점박이 3형제 스타일의 인터넷 주소는 확실히 관리하기 좋고 외우기에 좀더 편하긴 하지만 숫자로 되어 있다는 것 때문에 인터넷 서비스를 위한 호스트 주소로 사용하기엔 적당하지 않다. 그래서 인터넷 주소에 이름을 주는 서비스가 만들어지게 되었는데 도메인 서비스이다. 도메인 서비스는 도메인 이름에 대한 인터넷 주소를 되돌려 주는 일을 한다. 자세한 내용은 인터넷 주소 변환문서를 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.5.1. gethostbyname/gethostbyaddr
도메인 이름에서 인터넷 주소를 얻어오는 일을 한다. 자세한 내용은 gethostbyname(3)과 getbyaddr(3)의 맨페이지를 참고 하기바란다.
--------------------------------------------------------------------------------
1.4.6. 네트워크 바이트 오더
네트워크 통신을 하다보면 CPU의 바이트 오더가 다른 이유로 이를 표준 바이트 오더인 네트워크 바이트 오더로 변환해서 보내고, 받아들인 데이터는 호스트의 바이트 오더에 맞게 다시 변경시켜주는 작업이 필요하다. 이러한 작업을 위해서 소켓은 몇 개의 함수들을 제공한다. 바이트 오더에 대한 자세한 내용은 endian에 대해서 를 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.6.1. 호스트 바이트 오더 -> 네트워크 바이트 오더
htonl(3), htons(3) 함수를 사용한다. 전자는 4byte 데이터, 후자는 2byte 데이터를 네트워크 바이트 오더를 따르도록 변환한다.
--------------------------------------------------------------------------------
1.4.6.2. 네트워크 바이트 오더 -> 호스트 바이트 오더
ntohl(3), ntohs(3) 함수를 사용한다. 전자는 4byte데이터, 후자는 2byte데이터를 호스트 바이트 오더를 따르도록 변환한다.
--------------------------------------------------------------------------------
1.4.6.3. 엔디안 검사 함수
이건 보너스다. 현재 CPU의 바이트 오더 방식을 알려 주는 간단한 함수다. int endian(void)
{
int i = 0x00000001;
if ( ((char *)&i)[0] )
return LITTLE_ENDIAN;
else
return BIG_ENDIAN;
}
--------------------------------------------------------------------------------
1.4.7. 소켓 옵션
1.4.7.1. 소켓 옵션 설정 - setsockopt(2)
--------------------------------------------------------------------------------
1.4.7.2. 소켓 옵션 가져오기 - getsockopt(2)
--------------------------------------------------------------------------------
2. 소켓 프로그래밍 일반
2.1. 서버측 socket 생성 순서
다음은 서버측의 소켓 생성 순서를 나열한 것이다.
서버측의 소켓 생성순서는 최초 socket 함수를 이용해서 endpoint 소켓, 즉 클라이언트의 연결을 듣기 위한 소켓을 생성하게 된다. 이 소켓은 서버가 종료될때까지 남아있게 된다.
bind 함수를 호출하여 소켓특성을 묶어준다. 이 함수를 이용하여 port 번호를 지정해주며, 받아들일 IP주소에 대한 설정을 한다.
listen 함수를 이용하여 듣기 소켓(socket 함수를 통해서 만들어진) 에 연결이 있는지 기다린다. 만약 연결이 있다면, 연결 대기열(queue)에 쌓아놓는다.
accept 함수를 이용하여 연결 대기열에 대기중인 연결이 있다면 해당 연결에 대하여 새로운 소켓을 만들고 만들어진 소켓에 대한 파일 지시자를 되돌려준다. 이 소켓은 읽기/쓰기로 만들어진다. 만약 연결 대기열에 대기중인 연결이 없다면 (기본적으로) 해당 영역에서 봉쇄(block)된다.
read, write 등의 함수를 이용해서 통신을 한다.
--------------------------------------------------------------------------------
2.2. 클라이언트 측 socket 생성순서
다음은 클라이언트측의 소켓 생성 순서를 나열한 것이다. 서버측에 비하여서 훨신 간단하게 이루어짐을 알수 있다.
최초 socket 를 이용하여 endpoint 소켓을 생성한다. 클라이언트 이므로 이것은 듣기 소켓이 아니고, 연결 소켓이 될것이다. (이름만 다를뿐 사실 듣기 소켓과 연결 소켓의 구분은 없다)
connect 를 이용하여 서버에 연결한다.
read, write 등의 함수를 이용해서 서버와 통신한다.
--------------------------------------------------------------------------------
3. 결론
이상 Socket Layer 의 개념과 Socket Layer 에서 제공하는 Socket API 에 대한 간단히 알아 보았다. 여기에 있는 API 들은 가장 기본적인(통신을 위해서 필요한) 함수들이다. 나머지 좀더 세밀한 함수들에 대해서는 Unix NetWork Programming 등의 서적을 참고하기 바란다.
여기에 있는 소켓 API 들의 사용예는 이 사이트에서 충분히 찾아볼수 있을것이다.
1.1. Socket Layer
Socket 은 유닉스의 파일 기술자를 통해서 다른 프로그램간의 정보교환을 가능하도록 해주는 방법으로, 같은 시스템에 있는 프로그램들간의 정보교환을 위한목적, 혹은 다른 시스템(네트웍 상으로 멀리떨어져있는) 들간의 정보교환을 위한 목적으로 사용된다.
그런데 왜 Layer 라고 부르는가 그 이유는 TCP/IP 4계층의 응용계층(applicaton layer)과 전송계층(transmission layer) 중간에 존재하기 때문이다. 아래의 그림을 보라
그림 1. 소켓 계층
위의 그림은 TCP/IP 개요에서 이미 본적이 있는 그림일 것이다. 그때의 그림과 달라진 점이 있다면, 응용계층과 전송계층에 Socket Layer 가 존재한다는 것이다. 이 Socket Layer 가 응용계층과 전송계층 사이에 존재하게 됨으로 우리 프로그래머들은 복잡하게 TCP 를 직접 제어할 필요없이, Socket Layer 에서 제공하는 다양한 함수(Socket API)를 이용해서 간단하게 인터넷 네트웍 프로그래밍 작업을 하게 되는것이다.
Socket Layer 은 응용계층에서 받은 메시지를 하부 Socket API 를 이용해서 전송계층으로 보낸다. 전송계층에는 2가지 대표적인 프로토콜 이 있는데 바로 TCP 와 UDP 이다. 그럼으로 우리 프로그래머들은 TCP프로토콜을 사용할것인지 UDP 프로토콜을 사용할것인지만 결정해주면된다.
--------------------------------------------------------------------------------
1.2. 왜 Layer 구조를 가지는가
일상 생활에서 소켓레이어와 비슷한게, 전화기라고 볼수 있을것이다. 우리는 상대편에서 전화를 걸기 위해서 상대편전화의 지리적 위치가 어디인지, 어떤 전화국에서 관리하는지, 언어를 신호로 변환 시키기 위해서 어떠한 작업을 해야하는지, 어떻게 보내야 하는지 전혀 알필요가 없다. 그냥 수화기 들고 전화 번호만 누르면 그걸로 끝이다. 즉 전화기 라는게 있음으로 그 내부에서 일어나는 여러가지 복잡한 통신 프로세스를 모르고도 상대편과 전화통화를 할수 있게 된다.
Socket Layer 이 존재함으로써, 우리는 TCP/UDP 헤더를 어떻게 만들어야 하는지, 구조가 어떻게 되는지, 어떻게 커널에 전달해야 하는지 신경쓸필요 없이 네트웍 프로그램을 만들수 있게 된다.
--------------------------------------------------------------------------------
1.3. Socket
"Socket 이라뇨 우리는 위에서 Socket Layer를 이미 다루었는데요 ?" 라고 의문을 가질수도 있을것이다. Socket Layer 과 Socket 는 엄연히 다르다. Socket Layer 는 계층을 나타내는 것이다. 즉 Socket 를 다루기 위한 계층이다. 이는 TCP가 전송계층이 아닌것과 마찬가지이다. 우리는 Socket Layer 에서 제공하는 다양한 API를 통해서 Socket 를 제어하게 된다.
그럼 Socket 이란 무엇인가. 소켓이란 유닉스 파일 지시자 를 이용하여 다른 프로그램과 정보교환을 하는 방법(혹은 도구) 이다. 일반적으로 유닉스 상에서 정보교환은 파일지시자를 통한다는걸 알고 있을것이다. 마찬가지로 Socket 를 이용한 지역 혹은 네트웍으로 연결된 프로그램 간의 정보교환 역시 파일지시자를 통해서 이루어진다.
다중연결서버 만들기(1) 의 zipcode_multi.c 를 이용해서 소켓이 어떻게 작동하는지 알아보도록 하겠다. 먼저의 위의 프로그램을 컴파일 시키고 작동을 시켜보자. 작동을 시켰다면 ps 로 zipcode_multi 프로그램의 pid 를 확인해보고 /proc/pid/fd 디렉토리로 이동해서 어떠한 파일 지시자를 가지고 있는지 확인해보도록 하자. [yundream@localhost test]# ./zipcode_multi 4444
...
[yundream@localhost test]# ps -ax | grep zipcode
2473 ttyp1 S 0:00 ./zipcode_multi 4444
pid가 2473 이므로 이 프로그램의 /proc/2473/fd 로 이동해서 ls해보면 프로그램에서 사용하고있는 파일지시자들에 대해서 알수 있다. [yundream@localhost test]# ls -al /proc/2473/fd
합계 0
dr-x------ 2 root root 0 5월 28 16:07 .
dr-xr-xr-x 3 root root 0 5월 28 16:07 ..
lrwx------ 1 root root 64 5월 28 16:14 0 -> /dev/ttyp1
lrwx------ 1 root root 64 5월 28 16:14 1 -> /dev/ttyp1
lrwx------ 1 root root 64 5월 28 16:14 2 -> /dev/ttyp1
lr-x------ 1 root root 64 5월 28 16:14 3 -> /home/mycvs/test/zipcode.txt
lrwx------ 1 root root 64 5월 28 16:14 4 -> socket:[171434]
0, 1, 2 는 각각 표준입력, 표준출력, 표준에러를 가리키는 파일지시자 라는것은 이미 알고 있을것이다. 3 은 프로그램이 연 파일을 가리킨다. 마지막 4가 바로 socket 통신을 위해 만들어진 파일 지시자이다. 다른 것들이 터미널이나 파일을 가리키는것과는 달리 socket 를 가리 키고 있음을 알수 있다.
여기에 새로운 클라이언트가 접근을하면 (telnet 이나 전용클라이언트 를 이용해서) 다음과 같은 파일 지시자가 하나 추가 될것이다. lrwx------ 1 root root 64 5월 28 16:14 5 -> socket:[171435]
--------------------------------------------------------------------------------
1.4. socket API
이번에는 socket 레이어에서 제공하는 소켓 관련 함수들을 설명하도록 하겠다.
--------------------------------------------------------------------------------
1.4.1. 소켓 생성 및 연결
1.4.1.1. socket(2) 함수
이러한 소켓 은 socket(2) 함수를 이용해서 만들어진다. 최초 socket 함수를 이용해서 소켓을 생성하면 커널은 통신을 위한 종점(end point,즉 통신연결상황을 체크하는)을 생성하고, 여기에 대한 파일 지시자를 되돌려준다. 프로그램은 socket 함수를 이용해서 생성한 파일 지시자에 새로운 연결이 들어오는 지를 확인하게 된다.
위에 있는 TCP/IP 4계층을 보면 Socket Layer 아래에는 최소한 2개 이상의 사용가능한 데이타 그램의 타입이 있음을 알수 있다. 이러한 데이타 그램의 타입에는 TCP, UDP, RAW 등이 있다. TCP 소켓, UDP 소켓, RAW 소켓이라고 부르기도 한다. 또한 다양한 소켓 주소패밀리(군)를 제공한다.
표 1. 소켓주소 패밀리
UNIX 유닉스 도메인 소켓, IPC 용으로 많이 사용한다.
INET TCP/IP 프로토콜을 이용한 인터넷주소 패밀리, 보통의 네트웍프로그래밍시 주로 사용
IPX 노벨의 IPX 프로토콜, 게임을 좋아한다면 많이 들어봤음직한
AX25 아마추어 라디오 X.25
X25 X.25 프로토콜
그러므로 socket 함수는 위의 소켓 주소 패밀리와 소켓 타입 지정이 가능해야 한다. int socket(int domain, int type, int protocol);
첫번째 아규먼트가 소켓주소 패밀리 지정을 위해서 사용되며, 두번째 아규먼트가 소켓 타입지정을 위해서 사용된다. 소켓주소 패밀리는 주로 INET(AF_INET), UNIX(AF_UNIX) 가 사용되며, 소켓타입은 TCP(SOCK_STREAM), UDP(SOCK_DGRAM), RAW(SOCK_RAW) 가 사용된다.
즉 인터넷 프로토콜을 이용하는 TCP 소켓을 만들기 원한다면 socket(AF_INET, SOCK_STREAM, 0) 과 같이 사용하면 된다.
socket 함수가 성공적으로 수행되면, 사용가능한 소켓을 가르키는 파일 지시자를 되돌려주며, 이 파일지시자는 endpoint(연결 확인 통로) 로써 사용된다.
--------------------------------------------------------------------------------
1.4.1.2. bind(2) 함수
socket 함수를 이용해서 만들어진 소켓에 이름을 부여한다.
라고 번역된 man 페이지혹은 관련된 번역서에서 설명을 하고 있지만, 소켓에 특성을 부여(소켓과 특성을 묶는다(bind))한다 라는게 좀더 적당한 설명이 아닐까 싶다. int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
인자로 주어진 sockfd 에대해서 sockaddr 을 이용해서 특성을 묶어준다. bind 함수를 통해서 우리는 sockfd 가 사용할 포트번호(port), 그리고 연결을 받아들일 IP 주소 특성등을 묶어줄수 있다. IP 주소는 IPv4, IPv6 등이 사용될수 있을것이다.
bind 함수는 보통 서버에서 사용된다. 그 이유는 대부분의 서비스(HTTP, FTP..)들이 지정된 포트번호를 통해서 서비스 되기 때문이다. 반면 클라이언트의 경우 커널에서 할당한 임의의 포트번호를 이용해서 서버와 연결하기 때문에 bind 를 사용할 필요가 없다.
--------------------------------------------------------------------------------
1.4.1.3. connect(2) 함수
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
이것은 클라이언트측 에서 사용되며, struct sockaddr 구조체에 세팅된 내용대로 서버측에 연결한다. sockaddr 구조체에는 연결될 서버에 대한 정보들, 즉 주소 패밀리 IP 번호와 PORT 번호 등이 들어가 있으며, connect 함수는 sockaddr 정보를 이용해서 서버측에 연결을 하며 서버와의 통신을 위한 endpoint 와 sockfd 를 연결시킨다. sockfd 는 socket 함수를 이용해서 만들어진 소켓 지정 번호이다.
--------------------------------------------------------------------------------
1.4.1.4. listen(2) 함수
int listen(int sockfd, int backlog);
서버측에서 사용되며 socket 함수를 이용해서 만들어진 sockfd 에 대해서, 들어 오는 연결을 기다린다. backlog 는 아직 완전히 연결되지 않은 연결들이 대기할 queue 의 길이를 명시하기 위해서 사용된다.
--------------------------------------------------------------------------------
1.4.1.5. accept(2) 함수
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
listen(2) 을 통해서 만들어진 미연결의 대기열에서 가장 앞에 있는 연결의 내용을 가져와서 새로운 연결 소켓을 만들어준다. 새로만들어진 연결소켓은 파일 지시자를 할당하여서 리턴해주게 되며, 우리는 리턴된 파일 지시자를 이용해서 새로만들어진 소켓과 통신을 할수 있게 된다.
--------------------------------------------------------------------------------
1.4.2. 입출력 함수
유닉스에서 소켓은 파일과 동일하게 취급 되기 때문에 read(), write()와같은 시스템 함수를 이용해도 대부분의 입출력을 다룰 수 있다. 그러나 이들 시스템 함수들은 네트워크의 특성을 고려하지 않고 만들었기 때문에 네트워크 정보를 필요로 하는 작업을 하기에는 적당하지 않은 점이 있다.
예를들어 UDP를 이용해서 통신을 할경우 읽기는 문제없지만 쓰기에는 문제가 생길 수 있다. UDP는 연결 소켓을 만들지 않기 때문에 쓸때 연결된 호스트의 정보를 알 수가 없기 때문에 write()함수로는 데이터를 전송할 수 없게 된다. 이럴경우에는 소켓 API를 사용해서 통신을 해주어야 한다.
--------------------------------------------------------------------------------
1.4.2.1. 입력함수 - recvfrom/recvmsg
소켓으로 부터 데이터를 받기 위해서 사용한다.
#include
#include
ssize_t recvfrom(int s, void *buf, size_t len, int flags, struct sock-
addr *from, socklen_t *fromlen);
ssize_t recvmsg(int s, struct msghdr *msg, int flags);
소켓 지정자 s로 부터 데이터를 읽는 일을 한다. 둘다 연결지향 소켓과 비연결지향 소켓 모두에 사용할 수 있다. 보통 recvfrom()함수가 사용하기에 직관적인 관계로 쉽게 사용할 수 잇다. 소켓으로 부터 len 만큼 데이터를 읽어와서 buf에 저장한다. 또한 5번째 인자인 from를 통해서 데이터를 보낸 호스트의 인터넷 정보를 얻어 올 수 있다. 그러므로 비연결 지향 소켓을 사용하더라도 이 인터넷 정보를 통해서 데이터를 수신할 목적지 호스트를 결정할 수 있게 된다. fromlen는 sockaddr 구조체의 길이다. 나머지 자세한 내용은 recvform(2)의 맨페이지를 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.2.2. 출력함수 - sendto/sendmsg
소켓으로 데이터를 보내기 위해서 사용한다. #include
#include
ssize_t sendto(int s, const void *buf, size_t len, int flags, const
struct sockaddr *to, socklen_t tolen);
ssize_t sendmsg(int s, const struct msghdr *msg, int flags);
역시 직관적인 sendto를 널리 사용한다. 소켓 지정자 s에 len크기만큼 buf의 내용을 보낸다. to를 이용해서 데이터를 받을 호스트를 명시할 수 있다. sendto()와 recvfrom()함수의 사용예는 UDP 소켓 프로그래밍 을 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.3. 인터넷 주소변환
인터넷 주소 자체가 인간이 인지하기 어려운 수로 되어 있다 보니 이것을 관리하기 쉽도록 점박이 3형제 스타일의 인터넷 주소체계를 만들어서 관리하고 여기에 또 도메인 이름을 줘서 쉽게 기억할 수 있도록 하고 있다. 프로그래머나 사용자는 보통 도메인 이름이나 점박이 3형제 스타일의 인터넷 주소를 사용하게 되는데, 실제 네트워크 프로그램에서는 32bit 주소 형태로 변환 시켜줘야할 필요가 있다.
여기에서는 이들 주소간 변환과 관련된 함수를 소개한다.
--------------------------------------------------------------------------------
1.4.4. 인터넷 주소 <-> 32bit 주소
inet_addr(3), inet_aton(3), inet_network(3), inet_ntoa(3) 의 함수를 이용해서 인터넷 주소와 32bit 주소간 변환을 할 수 있다. inet_addr(3)과 inet_network(3)함수는 점박이 3형재 스타일 인터넷 주소로 부터 32bit 주소를 얻기 위해서, inet_aton(3)과 inet_ntoa(3)그 반대의 변환 값을 얻기 위해서 사용한다. 자세한 내용은 man 페이지를 참고하기 바란다(그냥 함수 링크를 클릭하면 된다).
--------------------------------------------------------------------------------
1.4.5. 도메인 이름 -> 32bit 주소
점박이 3형제 스타일의 인터넷 주소는 확실히 관리하기 좋고 외우기에 좀더 편하긴 하지만 숫자로 되어 있다는 것 때문에 인터넷 서비스를 위한 호스트 주소로 사용하기엔 적당하지 않다. 그래서 인터넷 주소에 이름을 주는 서비스가 만들어지게 되었는데 도메인 서비스이다. 도메인 서비스는 도메인 이름에 대한 인터넷 주소를 되돌려 주는 일을 한다. 자세한 내용은 인터넷 주소 변환문서를 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.5.1. gethostbyname/gethostbyaddr
도메인 이름에서 인터넷 주소를 얻어오는 일을 한다. 자세한 내용은 gethostbyname(3)과 getbyaddr(3)의 맨페이지를 참고 하기바란다.
--------------------------------------------------------------------------------
1.4.6. 네트워크 바이트 오더
네트워크 통신을 하다보면 CPU의 바이트 오더가 다른 이유로 이를 표준 바이트 오더인 네트워크 바이트 오더로 변환해서 보내고, 받아들인 데이터는 호스트의 바이트 오더에 맞게 다시 변경시켜주는 작업이 필요하다. 이러한 작업을 위해서 소켓은 몇 개의 함수들을 제공한다. 바이트 오더에 대한 자세한 내용은 endian에 대해서 를 참고하기 바란다.
--------------------------------------------------------------------------------
1.4.6.1. 호스트 바이트 오더 -> 네트워크 바이트 오더
htonl(3), htons(3) 함수를 사용한다. 전자는 4byte 데이터, 후자는 2byte 데이터를 네트워크 바이트 오더를 따르도록 변환한다.
--------------------------------------------------------------------------------
1.4.6.2. 네트워크 바이트 오더 -> 호스트 바이트 오더
ntohl(3), ntohs(3) 함수를 사용한다. 전자는 4byte데이터, 후자는 2byte데이터를 호스트 바이트 오더를 따르도록 변환한다.
--------------------------------------------------------------------------------
1.4.6.3. 엔디안 검사 함수
이건 보너스다. 현재 CPU의 바이트 오더 방식을 알려 주는 간단한 함수다. int endian(void)
{
int i = 0x00000001;
if ( ((char *)&i)[0] )
return LITTLE_ENDIAN;
else
return BIG_ENDIAN;
}
--------------------------------------------------------------------------------
1.4.7. 소켓 옵션
1.4.7.1. 소켓 옵션 설정 - setsockopt(2)
--------------------------------------------------------------------------------
1.4.7.2. 소켓 옵션 가져오기 - getsockopt(2)
--------------------------------------------------------------------------------
2. 소켓 프로그래밍 일반
2.1. 서버측 socket 생성 순서
다음은 서버측의 소켓 생성 순서를 나열한 것이다.
서버측의 소켓 생성순서는 최초 socket 함수를 이용해서 endpoint 소켓, 즉 클라이언트의 연결을 듣기 위한 소켓을 생성하게 된다. 이 소켓은 서버가 종료될때까지 남아있게 된다.
bind 함수를 호출하여 소켓특성을 묶어준다. 이 함수를 이용하여 port 번호를 지정해주며, 받아들일 IP주소에 대한 설정을 한다.
listen 함수를 이용하여 듣기 소켓(socket 함수를 통해서 만들어진) 에 연결이 있는지 기다린다. 만약 연결이 있다면, 연결 대기열(queue)에 쌓아놓는다.
accept 함수를 이용하여 연결 대기열에 대기중인 연결이 있다면 해당 연결에 대하여 새로운 소켓을 만들고 만들어진 소켓에 대한 파일 지시자를 되돌려준다. 이 소켓은 읽기/쓰기로 만들어진다. 만약 연결 대기열에 대기중인 연결이 없다면 (기본적으로) 해당 영역에서 봉쇄(block)된다.
read, write 등의 함수를 이용해서 통신을 한다.
--------------------------------------------------------------------------------
2.2. 클라이언트 측 socket 생성순서
다음은 클라이언트측의 소켓 생성 순서를 나열한 것이다. 서버측에 비하여서 훨신 간단하게 이루어짐을 알수 있다.
최초 socket 를 이용하여 endpoint 소켓을 생성한다. 클라이언트 이므로 이것은 듣기 소켓이 아니고, 연결 소켓이 될것이다. (이름만 다를뿐 사실 듣기 소켓과 연결 소켓의 구분은 없다)
connect 를 이용하여 서버에 연결한다.
read, write 등의 함수를 이용해서 서버와 통신한다.
--------------------------------------------------------------------------------
3. 결론
이상 Socket Layer 의 개념과 Socket Layer 에서 제공하는 Socket API 에 대한 간단히 알아 보았다. 여기에 있는 API 들은 가장 기본적인(통신을 위해서 필요한) 함수들이다. 나머지 좀더 세밀한 함수들에 대해서는 Unix NetWork Programming 등의 서적을 참고하기 바란다.
여기에 있는 소켓 API 들의 사용예는 이 사이트에서 충분히 찾아볼수 있을것이다.
2009년 6월 30일 화요일
[socket] 옵션 설정
소켓옵션
네트워크 환경은 매우 다양하며, 예측하기 힘든 경우도 많이 발생한다. 때문에 네트워크프로그램의 종류에 따라서 소켓의 세부사항을 조절해야 하는 경우가 발생한다. 이러한 소켓옵션 설정을 위해서 소켓은 getsockopt()와 setsockopt()두개의 함수를 제공한다. 이름에서 알 수 있듯이 getsockopt는 현재의 소켓옵션값을 가져오기 위해서, setsockopt는 소켓옵션값을 변경하기 위해서 사용한다.
예를 들자면 동일한 네트워 프로그램이라고 하더라도 ATM망에서 작동하는 것과 인터넷망 PPP에서 작동하는 것은 환경에 있어서 차이가 생길 수 밖에 없을 것이다. 소켓버퍼의 크기를 예로 들자면, 일반적으로 (대역폭 * 지연율) * 2의 공식에 따를 경우 최적의 효과를 보여준다고 한다. 다음은 이들 함수의 사용방법이다.
#include
#include
int getsockopt(int s, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int s, int level, int optname, const void *optval, socklen_t optlen);
s : 소켓지정번호
level : 소켓의 레벨로 어떤 레벨의 소켓정보를 가져오거나 변경할 것인지를 명시하며, SOL_SOCKET와 IPPROTO_TCP 중 하나를 사용할 수 있다.
optname : 설정을 위한 소켓옵션의 번호
optval : 설정값을 저장하기 위한 버퍼의 포인터
optlen : optval 버퍼의 크기
설정값을 void * 로 넘기는 이유는 설정하고자 하는 소켓옵션에 따라서, boolean, interger, 구조체등 다양한 크기를 가지는 데이터형이 사용되기 때문이다. 만약 변경하고자 하는 소켓옵션이 boolean을 따른다면, 0혹은 1이 사용될 것이다.
SOL_SOCKET레벨에서 사용할 수 있는 옵션과 데이타형은 다음과 같다.
옵션값 데이터형 설명
SO_BROADCAST BOOL 브로드캐스트 메시지 전달이 가능하도록 한다.
SO_DEBUG BOOL 디버깅 정보를 레코딩 한다.
SO_DONTLINGER BOOL 소켓을 닫을때 보내지 않은 데이터를 보내기 위해서 블럭되지 않도록 한다.
SO_DONTROUTE BOOL 라우팅 하지 않고 직접 인터페이스로 보낸다.
SO_GROUP_PRIORITY int 사용하지 않음
SO_KEEPALIVE BOOL Keepalives를 전달한다.
SO_LINGER struct LINGER 소켓을 닫을 때 전송되지 않은 데이터의 처리 규칙
SO_RCVBUF int 데이터를 수신하기 위한 버퍼공간의 명시
SO_REUSEADDR BOOL 이미 사용된 주소를 재사용 (bind) 하도록 한다.
SO_SNDBUF int 데이터 전송을 위한 버퍼공간 명시
IPPROTO_TCP레벨에서 사용할 수 있는 옵션과 데이터형이다.
TCP_NODELAY BOOL Nagle 알고리즘 제어
SO_REUSEADDR
간단한 예로, 소켓을 이용한 서버프로그램을 운용하다 보면 강제종료되거나 비정상 종료되는 경우가 발생한다. 테스트를 목적으로 할 경우에는 특히 강제종료 시켜야 하는 경우가 자주 발생하는데, 강제종료 시키고 프로그램을 다시 실행시킬경우 다음과 같은 메시지를 종종 보게 된다.
bind error : Address already in use
이는 기존 프로그램이 종료되었지만, 비정상종료된 상태로 아직 커널이 bind정보를 유지하고 있음으로 발생하는 문제다. 보통 1-2분 정도 지나만 커널이 알아서 정리를 하긴 하지만, 그 시간동안 기달려야 한다는 것은 상당히 번거로운 일이 될 것이다. 이 경우 다음과 같은 코드를 삽입함으로써 문제를 해결할 수 있다.
int sock = socket(...);
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char *)&bf, (int)sizeof(bf));
이렇게 하면 커널은 기존에 bind로 할당된 소켓자원을 프로세스가 재 사용할 수 있도록 허락하게 된다.
다음은 소켓버퍼의 크기를 가져오고 설정하는 완전한 코드다.
#include
#include
#include
#include
#include
#include
int main(int argc, char **argv)
{
int sockfd;
int bufsize;
int rn;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
perror("Error");
return 1;
}
rn = sizeof(int);
// 현재 RCVBUF 값을 얻어온다.
if (getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufsize, (socklen_t *)&rn) < 0)
{
perror("Set Error");
return 1;
}
printf("Socket RCV Buf Size is %d\n", bufsize);
// 버퍼의 크기를 100000 으로 만든다.
bufsize = 100000;
if (setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, (void *)&bufsize, (socklen_t)rn) < 0)
{
perror("Set Error");
return 1;
}
return 0;
}
TCP_NODELAY
이 옵션을 이해하려면 Nagle알고리즘에 대해서 이해를 해야 한다. Nagle 알고리즘이 적용되면, 운영체제는 패킷을 ACK가 오기를 기다렸다가 도착하면, 그 동안 쌓여있던 데이터를 한꺼번에 보내게 된다. 이러한 방식을 사용하게 되면, 대역폭이 낮은 WAN에서 빈번한 전송을 줄이게 됨으로 효과적인 대역폭활용이 가능해진다.
대부분의 경우에 있어서 Nagle 알고리즘은 효율적으로 작동하긴 하지만, 빈번한 응답이 중요한 서비스의 경우에는 적당하지 않은 경우가 발생한다. 예를 들어 X-Terminal을 이용할 경우 마우스 이벤트는 즉시 전달될 필요가 있는데, Nagle알고리즘을 사용하면 아무래도 반응시간이 떨어지게 될 것이다. 실시간적인 반응이 중요한 온라인 게임역시 Nagle 알고리즘을 제거하는게 좋을 것이다.
아래의 이미지는 nagle이 적용되었을 때와 그렇지 않을 때, 어떻게 데이터 전송이 일어나는지를 보여주고 있다.
SO_LINGER
SO_LINGER은 소켓이 close()되었을 때, 소켓버퍼에 남아있는 데이터를 어떻게 할 것이지를 결정하기 위해서 사용한다. 다음은 SO_LINGER 옵션에 사용되는 데이터구조체이다.
struct linger
{
int l_onoff;
int l_linger;
}
l_onoff : linger 옵션을 끌것인지 킬 것인지 결정
l_linger : 기다리는 시간의 결정
위의 두개의 멤버변수의 값을 어떻게 하느냐에 따라 3가지 close방식을 결정되어 진다.
l_onoff == 0 : 이 경우 l_linger의 영향을 받지 않는다. 소켓의 기본설정으로 소켓버퍼에 남아 있는 모든 데이터를 보낸다. 이때 close()는 바로 리턴을 하게 되므로 백그라운드에서 이러한 일이 일어나게 된다. 우아한 연결 종료를 보장한다.
l_onoff > 0 이고 l_linger == 0 : close()는 바로 리턴을 하며, 소켓버퍼에 아직 남아있는 데이터는 버려 버린다. TCP 연결상태일 경우에는 상대편 호스트에 리셋을 위한 RST 패킷을 보낸다. hard 혹은 abortive 종료라고 부른다.
l_onoff > 0 이고 l_linger > 0 : 버퍼에 남아있는 데이터를 모두 보내는 우아한 연결 종료를 행한다. 이때 close()에서는 l_linger에 지정된 시간만큼 블럭상태에서 대기한다. 만약 지정된 시간내에 데이터를 모두 보냈다면 리턴이 되고, 시간이 초과되었다면 에러와 함께 리턴이 된다.
네트워크 환경은 매우 다양하며, 예측하기 힘든 경우도 많이 발생한다. 때문에 네트워크프로그램의 종류에 따라서 소켓의 세부사항을 조절해야 하는 경우가 발생한다. 이러한 소켓옵션 설정을 위해서 소켓은 getsockopt()와 setsockopt()두개의 함수를 제공한다. 이름에서 알 수 있듯이 getsockopt는 현재의 소켓옵션값을 가져오기 위해서, setsockopt는 소켓옵션값을 변경하기 위해서 사용한다.
예를 들자면 동일한 네트워 프로그램이라고 하더라도 ATM망에서 작동하는 것과 인터넷망 PPP에서 작동하는 것은 환경에 있어서 차이가 생길 수 밖에 없을 것이다. 소켓버퍼의 크기를 예로 들자면, 일반적으로 (대역폭 * 지연율) * 2의 공식에 따를 경우 최적의 효과를 보여준다고 한다. 다음은 이들 함수의 사용방법이다.
#include
#include
int getsockopt(int s, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int s, int level, int optname, const void *optval, socklen_t optlen);
s : 소켓지정번호
level : 소켓의 레벨로 어떤 레벨의 소켓정보를 가져오거나 변경할 것인지를 명시하며, SOL_SOCKET와 IPPROTO_TCP 중 하나를 사용할 수 있다.
optname : 설정을 위한 소켓옵션의 번호
optval : 설정값을 저장하기 위한 버퍼의 포인터
optlen : optval 버퍼의 크기
설정값을 void * 로 넘기는 이유는 설정하고자 하는 소켓옵션에 따라서, boolean, interger, 구조체등 다양한 크기를 가지는 데이터형이 사용되기 때문이다. 만약 변경하고자 하는 소켓옵션이 boolean을 따른다면, 0혹은 1이 사용될 것이다.
SOL_SOCKET레벨에서 사용할 수 있는 옵션과 데이타형은 다음과 같다.
옵션값 데이터형 설명
SO_BROADCAST BOOL 브로드캐스트 메시지 전달이 가능하도록 한다.
SO_DEBUG BOOL 디버깅 정보를 레코딩 한다.
SO_DONTLINGER BOOL 소켓을 닫을때 보내지 않은 데이터를 보내기 위해서 블럭되지 않도록 한다.
SO_DONTROUTE BOOL 라우팅 하지 않고 직접 인터페이스로 보낸다.
SO_GROUP_PRIORITY int 사용하지 않음
SO_KEEPALIVE BOOL Keepalives를 전달한다.
SO_LINGER struct LINGER 소켓을 닫을 때 전송되지 않은 데이터의 처리 규칙
SO_RCVBUF int 데이터를 수신하기 위한 버퍼공간의 명시
SO_REUSEADDR BOOL 이미 사용된 주소를 재사용 (bind) 하도록 한다.
SO_SNDBUF int 데이터 전송을 위한 버퍼공간 명시
IPPROTO_TCP레벨에서 사용할 수 있는 옵션과 데이터형이다.
TCP_NODELAY BOOL Nagle 알고리즘 제어
SO_REUSEADDR
간단한 예로, 소켓을 이용한 서버프로그램을 운용하다 보면 강제종료되거나 비정상 종료되는 경우가 발생한다. 테스트를 목적으로 할 경우에는 특히 강제종료 시켜야 하는 경우가 자주 발생하는데, 강제종료 시키고 프로그램을 다시 실행시킬경우 다음과 같은 메시지를 종종 보게 된다.
bind error : Address already in use
이는 기존 프로그램이 종료되었지만, 비정상종료된 상태로 아직 커널이 bind정보를 유지하고 있음으로 발생하는 문제다. 보통 1-2분 정도 지나만 커널이 알아서 정리를 하긴 하지만, 그 시간동안 기달려야 한다는 것은 상당히 번거로운 일이 될 것이다. 이 경우 다음과 같은 코드를 삽입함으로써 문제를 해결할 수 있다.
int sock = socket(...);
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char *)&bf, (int)sizeof(bf));
이렇게 하면 커널은 기존에 bind로 할당된 소켓자원을 프로세스가 재 사용할 수 있도록 허락하게 된다.
다음은 소켓버퍼의 크기를 가져오고 설정하는 완전한 코드다.
#include
#include
#include
#include
#include
#include
int main(int argc, char **argv)
{
int sockfd;
int bufsize;
int rn;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
perror("Error");
return 1;
}
rn = sizeof(int);
// 현재 RCVBUF 값을 얻어온다.
if (getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufsize, (socklen_t *)&rn) < 0)
{
perror("Set Error");
return 1;
}
printf("Socket RCV Buf Size is %d\n", bufsize);
// 버퍼의 크기를 100000 으로 만든다.
bufsize = 100000;
if (setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, (void *)&bufsize, (socklen_t)rn) < 0)
{
perror("Set Error");
return 1;
}
return 0;
}
TCP_NODELAY
이 옵션을 이해하려면 Nagle알고리즘에 대해서 이해를 해야 한다. Nagle 알고리즘이 적용되면, 운영체제는 패킷을 ACK가 오기를 기다렸다가 도착하면, 그 동안 쌓여있던 데이터를 한꺼번에 보내게 된다. 이러한 방식을 사용하게 되면, 대역폭이 낮은 WAN에서 빈번한 전송을 줄이게 됨으로 효과적인 대역폭활용이 가능해진다.
대부분의 경우에 있어서 Nagle 알고리즘은 효율적으로 작동하긴 하지만, 빈번한 응답이 중요한 서비스의 경우에는 적당하지 않은 경우가 발생한다. 예를 들어 X-Terminal을 이용할 경우 마우스 이벤트는 즉시 전달될 필요가 있는데, Nagle알고리즘을 사용하면 아무래도 반응시간이 떨어지게 될 것이다. 실시간적인 반응이 중요한 온라인 게임역시 Nagle 알고리즘을 제거하는게 좋을 것이다.
아래의 이미지는 nagle이 적용되었을 때와 그렇지 않을 때, 어떻게 데이터 전송이 일어나는지를 보여주고 있다.
SO_LINGER
SO_LINGER은 소켓이 close()되었을 때, 소켓버퍼에 남아있는 데이터를 어떻게 할 것이지를 결정하기 위해서 사용한다. 다음은 SO_LINGER 옵션에 사용되는 데이터구조체이다.
struct linger
{
int l_onoff;
int l_linger;
}
l_onoff : linger 옵션을 끌것인지 킬 것인지 결정
l_linger : 기다리는 시간의 결정
위의 두개의 멤버변수의 값을 어떻게 하느냐에 따라 3가지 close방식을 결정되어 진다.
l_onoff == 0 : 이 경우 l_linger의 영향을 받지 않는다. 소켓의 기본설정으로 소켓버퍼에 남아 있는 모든 데이터를 보낸다. 이때 close()는 바로 리턴을 하게 되므로 백그라운드에서 이러한 일이 일어나게 된다. 우아한 연결 종료를 보장한다.
l_onoff > 0 이고 l_linger == 0 : close()는 바로 리턴을 하며, 소켓버퍼에 아직 남아있는 데이터는 버려 버린다. TCP 연결상태일 경우에는 상대편 호스트에 리셋을 위한 RST 패킷을 보낸다. hard 혹은 abortive 종료라고 부른다.
l_onoff > 0 이고 l_linger > 0 : 버퍼에 남아있는 데이터를 모두 보내는 우아한 연결 종료를 행한다. 이때 close()에서는 l_linger에 지정된 시간만큼 블럭상태에서 대기한다. 만약 지정된 시간내에 데이터를 모두 보냈다면 리턴이 되고, 시간이 초과되었다면 에러와 함께 리턴이 된다.
2009년 6월 17일 수요일
[omp] directive, fuction, macro 정리
- #pragma omp ordered : 병령 루프는 동기화 없이 반복된다. 그러나 이러한 병렬 실행을 막고 순차적으로 실행해야 하는 부분에서 이 를 사용한다.
ex)
omp_set_num_thread(4);
#pragma omp parallel private(myid);
{
myid = omp_get_thread_num();
#pragma omp for private(i) ordered
for(int i=0; i<8; i++)
{
#pragma omp ordered
printf("T:%d,i=%d\n", myid, i);
}
}
-. 데이터 유효범위
OMP의 쓰레드별 데이터 변수별로 사용 범위(개별로 쓸것인지, 아니면 공유하여 쓸것인지)를 지정 할
수 있다.
기본적으로 OMP의 루프변수(루프의 count인자)는 private로 지정되며, 이외의 모든 변수는 shared
로 자동적으로 지정된다.
OMP는 이를 수동으로 사용자가 지정할 수 있도록 clause를 제공한다.
private(변수, 변수...)
shread(변수, 변수...)
-. omp_set_dynamic
사용자가 지정한 개수로만 동적 개수로 쓰레드를 생성하여 시스템의 상황에 맞도록 병렬 실행한다.
작업분할(do/for, section, single)
do/for
=> (균등한 분할)
Master Thread => Fork => Thread Team => JOIN
=>
section
=> (편중된 분할)
Master Thread =>Fork => Thread Team => JOIN
=>
single
=> (Only 1 Thread)
Master Thread =>Fork => Thread Team => JOIN
=>
FORK 후의 JOIN시 어떠한 쓰게드라도 모두 종료되어야만 쓰레드 팀이 소거된다.
다른 쓰레드가 종료되기를 기다리지 않고, 다른 작업을 계속하려면 nowait clause를 사용해야 한다.
-. single 지시어는 암시적인 장벽이 없어 가정 먼저 실행된 스레드만이 signle 코드 블럭을 실행하게 된다.
만약 nowait가 없다면 signle 코드를 실행한 스레드가 종료해야만, 다른 스레드도 종료하게 된다.
<결합도 작업 구문 clause>
#pragma omp parallel
#pragma omp for ==> #pragma omp parallel for
#pragma omp parallel
#pragma omp sections ==> #pragma omp parallel sections
변수 영역 관련
-. 병렬 구문 아래의 순차적으로 실행되는 for 루프의 인자는 기본적으로 shared를 가지게 된다.
따라서 명시적으로 루프의 인자는 private clause를 사용 할 필요가 있다.
-. 병렬 구문 아래의 서브 루틴의 지역변수는 기본적으로 private 속성을 가지게 된다.
그러나 static 변수경우 쓰레들간 공유하게 된다.
-. 병렬 구문 아래의 루프안의 수시로 선언되는 지역변수는 private가 된다.
-. defualt(none)을 사용하여, 병렬구문 내의 모든 변수가 shared또는 private로 미리 선언되어야
한다.
Reduction Clause
병렬로 처리된 결과를 마지막에 모두 취합하여 계산해야 하는 변수에 대하여 사용한다.
예)
#pragma omp parallel for
for(int i=0; i<100; i++)
sum += a + b;
위 변수 sum은 병렬로 처리되어 thread마다 소유하게 되고, join시 team thread 별 sum변수는 다
른 값을 가지게 되어 스레드별로 모두 취합하여 따로 계산해 줘야하는 경우에는
reduction clause를 사용하여 이러한 과정을 자동적으로 처리하도록 할 수 있다.
다음은 reduction clause를 사용한 예이다.
#pragma omp parallel for reduction(+:sum)
for(int i=0; i<100; i++)
sum += a+b;
reduction 사용시 주의점
우선 순위에 영향을 받게되는 연산의 경우에는 reduction 사용시 나중에 어떻한 값이 나오게 될 지 장담할 수 없게 된다.
즉, 먼저 실행이 종료된 스레드의 값이 우선순위가 높게되며, 나중에 종료된 스레드 값이 우선순위가 낮아 결과값으로 취합할 경우 장담할 수 없는 값이 나오게 되는데 이러한 경우를 막기위해서는 추가적인
코드 처리를 해줘야 한다.
또한 reduction 변수는 스칼라 형이여야하고, 스레드 팀에서 모두 공유 할 수 있는 shared 타입이여야 한다.
따라서 배열이나 구조체의 경우 reduction 변수가 될 수 없다.
scheduale
staic(nochunk)
staic(chunk)
ex)
omp_set_num_thread(4);
#pragma omp parallel private(myid);
{
myid = omp_get_thread_num();
#pragma omp for private(i) ordered
for(int i=0; i<8; i++)
{
#pragma omp ordered
printf("T:%d,i=%d\n", myid, i);
}
}
-. 데이터 유효범위
OMP의 쓰레드별 데이터 변수별로 사용 범위(개별로 쓸것인지, 아니면 공유하여 쓸것인지)를 지정 할
수 있다.
기본적으로 OMP의 루프변수(루프의 count인자)는 private로 지정되며, 이외의 모든 변수는 shared
로 자동적으로 지정된다.
OMP는 이를 수동으로 사용자가 지정할 수 있도록 clause를 제공한다.
private(변수, 변수...)
shread(변수, 변수...)
-. omp_set_dynamic
사용자가 지정한 개수로만 동적 개수로 쓰레드를 생성하여 시스템의 상황에 맞도록 병렬 실행한다.
작업분할(do/for, section, single)
do/for
=> (균등한 분할)
Master Thread => Fork => Thread Team => JOIN
=>
section
=> (편중된 분할)
Master Thread =>Fork => Thread Team => JOIN
=>
single
=> (Only 1 Thread)
Master Thread =>Fork => Thread Team => JOIN
=>
FORK 후의 JOIN시 어떠한 쓰게드라도 모두 종료되어야만 쓰레드 팀이 소거된다.
다른 쓰레드가 종료되기를 기다리지 않고, 다른 작업을 계속하려면 nowait clause를 사용해야 한다.
-. single 지시어는 암시적인 장벽이 없어 가정 먼저 실행된 스레드만이 signle 코드 블럭을 실행하게 된다.
만약 nowait가 없다면 signle 코드를 실행한 스레드가 종료해야만, 다른 스레드도 종료하게 된다.
<결합도 작업 구문 clause>
#pragma omp parallel
#pragma omp for ==> #pragma omp parallel for
#pragma omp parallel
#pragma omp sections ==> #pragma omp parallel sections
변수 영역 관련
-. 병렬 구문 아래의 순차적으로 실행되는 for 루프의 인자는 기본적으로 shared를 가지게 된다.
따라서 명시적으로 루프의 인자는 private clause를 사용 할 필요가 있다.
-. 병렬 구문 아래의 서브 루틴의 지역변수는 기본적으로 private 속성을 가지게 된다.
그러나 static 변수경우 쓰레들간 공유하게 된다.
-. 병렬 구문 아래의 루프안의 수시로 선언되는 지역변수는 private가 된다.
-. defualt(none)을 사용하여, 병렬구문 내의 모든 변수가 shared또는 private로 미리 선언되어야
한다.
Reduction Clause
병렬로 처리된 결과를 마지막에 모두 취합하여 계산해야 하는 변수에 대하여 사용한다.
예)
#pragma omp parallel for
for(int i=0; i<100; i++)
sum += a + b;
위 변수 sum은 병렬로 처리되어 thread마다 소유하게 되고, join시 team thread 별 sum변수는 다
른 값을 가지게 되어 스레드별로 모두 취합하여 따로 계산해 줘야하는 경우에는
reduction clause를 사용하여 이러한 과정을 자동적으로 처리하도록 할 수 있다.
다음은 reduction clause를 사용한 예이다.
#pragma omp parallel for reduction(+:sum)
for(int i=0; i<100; i++)
sum += a+b;
reduction 사용시 주의점
우선 순위에 영향을 받게되는 연산의 경우에는 reduction 사용시 나중에 어떻한 값이 나오게 될 지 장담할 수 없게 된다.
즉, 먼저 실행이 종료된 스레드의 값이 우선순위가 높게되며, 나중에 종료된 스레드 값이 우선순위가 낮아 결과값으로 취합할 경우 장담할 수 없는 값이 나오게 되는데 이러한 경우를 막기위해서는 추가적인
코드 처리를 해줘야 한다.
또한 reduction 변수는 스칼라 형이여야하고, 스레드 팀에서 모두 공유 할 수 있는 shared 타입이여야 한다.
따라서 배열이나 구조체의 경우 reduction 변수가 될 수 없다.
scheduale
staic(nochunk)
staic(chunk)
[omp] 개념
Types of Parallel Programming
Before we begin with OpenMP, it is important to know why we need parallel processing. In a typical case, a sequential code will execute in a thread which is executed on a single processing unit. Thus, if a computer has 2 processors or more ( or 2 cores, or 1 processor with HyperThreading), only a single processor will be used for execution, thus wasting the other processing power. Rather than letting the other processor to sit idle (or process other threads from other programs) we can use it to speed up our algorithm.
Parallel processing can be divided in to two groups, task based and data based.
Task based : Divide different tasks to different CPUs to be executed in parallel. For example, a Printing thread and a Spell Checking thread running simultaneously in a word processor. Each thread is a separate task.
Data based : Execute the same task, but divide the work load on the data over several CPUs. For example, to convert a color image to grayscale. We can convert the top half of the image on the first CPU, while the lower half is converted on the second CPU (or as many CPUs you have), thus processing in half the time.
There are several methods to do parallel processing
Use MPI : Mesage Passing Interface - MPI is most suited for a system with multiple processors and multiple memory. For example, a cluster of computers with their own local memory. You can use MPI to divide workload across this cluster, and merge the result when it is finished. Available with Microsoft Compute Cluster Pack.
Use OpenMP : OpenMP is suited for shared memory systems like we have on our desktop computers. Shared memory systems are systems with multiple processors but each are sharing a single memory subsystem. Using OpenMP is just like writing your own smaller threads but let the compiler do it. Available in Visual Studio 2005 Professional and Team Suite.
Use SIMD intrinsics : Single Instruction Multiple Data (SIMD) has been available on mainstream processors such as Intel's MMX, SSE, SSE2, SSE3, Motorola's (or IBM's) Altivec and AMD's 3DNow!. SIMD intrinsincs are primitive functions to parallelize data processing on the CPU register level. For example, the addition of two unsigned char will take the whole register size, although the size of this data type is just 8-bit, leaving 24-bit in the register to be filled with 0 and wasted. Using SIMD (such as MMX), we can load 8 unsigned chars (or 4 shorts or 2 integers) to be executed in parallel on the register level. Available in Visual Studio 2005 using SIMD intrinsics or with Visual C++ Processor Pack with Visual C++ 6.0.
Understanding the Fork-and-Join Model
OpenMP uses the fork-and-join parallelism model. In fork-and-join, parallel threads are created and branched out from a master thread to execute an operation and will only remain until the operation has finished, then all the threads are destroyed, thus leaving only one master thread.
The process of splitting and joining of threads including synchronization for end result are handled by OpenMP.
How Many Threads Do I Need?
A typical question is that how many threads do I actually need? Are more threads better? How do I control the number of threads in my code? Are number of threads related to number of CPUs?
The number of threads required to solve a problem is generally limited to the number of CPUs you have. As you can see in the Fork-and-Join figure above, whenever threads are created, a little time is taken to create a thread and later to join the end result and destroy the threads. When the problem is small, and the number of CPUs are less than the number of threads, the total execution time will be longer (slower) because more time has been spent to create threads, and later switch between the threads (due to preemptive behaviour) then to actually solve the problem. Whenever a thread context is switched, data must be saved/loaded from the memory. This takes time.
The rule is simple, since all the threads will be executing the same operation (hence the same priority), 1 thread is sufficient per CPU (or core). The more CPUs you have, the more threads you can create.
Most compiler directives in OpenMP uses the Environment Variable OMP_NUM_THREADS to determine the number of threads to create. You can control the number of threads with the following functions;
// Get the number of processors in this system
int iCPU = omp_get_num_procs();
// Now set the number of threads
omp_set_num_threads(iCPU);
Of course, you can put any value for iCPU in the code above (if you do not want to call omp_get_num_procs), and you can call the omp_set_num_threads functions as many times as you like for different parts of your code for maximum control. If omp_set_num_threads is not called, OpenMP will use the OMP_NUM_THREADS Environment Variable.
Parallel for Loop
Let us start with a simple parallel for loop. The following is a code to convert a 32-bit Color (RGBA) image to 8-bit Grayscale image
// pDest is an unsigned char array of size width * height
// pSrc is an unsigned char array of size width * height * 4 (32-bit)
// To avoid floating point operation, all floating point weights in the original
// grayscale formula
// has been changed to integer approximation
// Use pragma for to make a parallel for loop
omp_set_num_threads(threads);
#pragma omp parallel for
for(int z = 0; z < height*width; z++)
{
pDest[z] = (pSrc[z*4+0]*3735 + pSrc[z*4 + 1]*19234+ pSrc[z*4+ 2]*9797)>>15;
}
The #pragma omp parallel for directive will parallelize the for loop according to the number of threads set. The following is the performance gained for a 3264x2488 image on a 1.66GHz Core Duo system (2 Cores).
Thread(s) : 1 Time 0.04081 sec
Thread(s) : 2 Time 0.01906 sec
Thread(s) : 4 Time 0.01940 sec
Thread(s) : 6 Time 0.02133 sec
Thread(s) : 8 Time 0.02029 sec
As you can see, by executing the problem using 2 threads on a dual-core CPU, the time has been cut by half. However, as the number of threads is increased, the performance does not due to increased time to fork and join.
Parallel double for loop
The same problem above (converting color to grayscale) can also be written in a double for loop way. This can be written like this;
Collapse Copy Code
for(int y = 0; y < height; y++)
for(int x = 0; x< width; x++)
pDest[x+y*width] = (pSrc[x*4 + y*4*width + 0]*3735 + pSrc[x*4 + y*4*width + 1]*19234+ pSrc[x*4 + y*4*width + 2]*9797)>>15;
In this case, there are two solutions;
Solution 1
We have made the inner loop parallel using the parallel for directive. As you can see, when 2 threads are being used, the execution time has actually increased! This is because, for every iteration of y, a fork-join operation is performed, and at the end contributed to the increased execution time.
Collapse Copy Code
for(int y = 0; y < height; y++)
{
#pragma omp parallel for
for(int x = 0; x< width; x++)
{
pDest[x+y*width] = (pSrc[x*4 + y*4*width + 0]*3735 + pSrc[x*4 + y*4*width + 1]*19234+ pSrc[x*4 + y*4*width + 2]*9797)>>15;
}
}
Thread(s) : 1 Time 0.04260 sec
Thread(s) : 2 Time 0.05171 sec
Solution 2
Instead of making the inner loop parallel, the outer loop is the better choice. Here, another directive is introduced - the private directive. The private clause directs the compiler to make variables private so multiple copies of a variable does not execute. Here, you can see that the execution time did indeed get reduced by half.
int x = 0;
#pragma omp parallel for private(x)
for(int y = 0; y < height; y++)
{
for(x = 0; x< width; x++)
{
pDest[x+y*width] = (pSrc[x*4 + y*4*width + 0]*3735 + pSrc[x*4 + y*4*width + 1]*19234+ pSrc[x*4 + y*4*width + 2]*9797)>>15;
}
}
Thread(s) : 1 Time 0.04039 sec
Thread(s) : 2 Time 0.02020 sec
Get to know the number of threads
At any time, we can obtain the number of OpenMP threads running by calling the function int omp_get_thread_num(); .
Summary
By using OpenMP, you can gain performance on multi-core systems for free, without much coding other than a line or too. There is no excuse not to use OpenMP. The benefits are there, and the coding is simple.
There are several other OpenMP directives such as firstprivate, critical sections and reductions which are left for another article. :)
Points of Interest
You can also perform similar operation using Windows threads, but it will be more difficult to implement, because of issues such as synchronization and thread management.
Additional Notes
The original RGB to Grayscale formula is given as Y = 0.299 R + 0.587 G + 0.114 B.
References
Parallel Programming in C with MPI and OpenMP (1st ed.) , Michael J. Quinn, McGraw Hill, 2004
Scientific Parallel Computing, L. Ridgway Scott, Terry Clark, Babak Bagheri, Princeton University Press, 2005
Introduction to Parallel Computing: A Practical Guide with Examples in An W.P. Petersen, P. Arbenz, Oxford University Press, 2004.
Digital Color Imaging Handbook, Gaurav Sharma, CRC Press, 2003
Before we begin with OpenMP, it is important to know why we need parallel processing. In a typical case, a sequential code will execute in a thread which is executed on a single processing unit. Thus, if a computer has 2 processors or more ( or 2 cores, or 1 processor with HyperThreading), only a single processor will be used for execution, thus wasting the other processing power. Rather than letting the other processor to sit idle (or process other threads from other programs) we can use it to speed up our algorithm.
Parallel processing can be divided in to two groups, task based and data based.
Task based : Divide different tasks to different CPUs to be executed in parallel. For example, a Printing thread and a Spell Checking thread running simultaneously in a word processor. Each thread is a separate task.
Data based : Execute the same task, but divide the work load on the data over several CPUs. For example, to convert a color image to grayscale. We can convert the top half of the image on the first CPU, while the lower half is converted on the second CPU (or as many CPUs you have), thus processing in half the time.
There are several methods to do parallel processing
Use MPI : Mesage Passing Interface - MPI is most suited for a system with multiple processors and multiple memory. For example, a cluster of computers with their own local memory. You can use MPI to divide workload across this cluster, and merge the result when it is finished. Available with Microsoft Compute Cluster Pack.
Use OpenMP : OpenMP is suited for shared memory systems like we have on our desktop computers. Shared memory systems are systems with multiple processors but each are sharing a single memory subsystem. Using OpenMP is just like writing your own smaller threads but let the compiler do it. Available in Visual Studio 2005 Professional and Team Suite.
Use SIMD intrinsics : Single Instruction Multiple Data (SIMD) has been available on mainstream processors such as Intel's MMX, SSE, SSE2, SSE3, Motorola's (or IBM's) Altivec and AMD's 3DNow!. SIMD intrinsincs are primitive functions to parallelize data processing on the CPU register level. For example, the addition of two unsigned char will take the whole register size, although the size of this data type is just 8-bit, leaving 24-bit in the register to be filled with 0 and wasted. Using SIMD (such as MMX), we can load 8 unsigned chars (or 4 shorts or 2 integers) to be executed in parallel on the register level. Available in Visual Studio 2005 using SIMD intrinsics or with Visual C++ Processor Pack with Visual C++ 6.0.
Understanding the Fork-and-Join Model
OpenMP uses the fork-and-join parallelism model. In fork-and-join, parallel threads are created and branched out from a master thread to execute an operation and will only remain until the operation has finished, then all the threads are destroyed, thus leaving only one master thread.
The process of splitting and joining of threads including synchronization for end result are handled by OpenMP.
How Many Threads Do I Need?
A typical question is that how many threads do I actually need? Are more threads better? How do I control the number of threads in my code? Are number of threads related to number of CPUs?
The number of threads required to solve a problem is generally limited to the number of CPUs you have. As you can see in the Fork-and-Join figure above, whenever threads are created, a little time is taken to create a thread and later to join the end result and destroy the threads. When the problem is small, and the number of CPUs are less than the number of threads, the total execution time will be longer (slower) because more time has been spent to create threads, and later switch between the threads (due to preemptive behaviour) then to actually solve the problem. Whenever a thread context is switched, data must be saved/loaded from the memory. This takes time.
The rule is simple, since all the threads will be executing the same operation (hence the same priority), 1 thread is sufficient per CPU (or core). The more CPUs you have, the more threads you can create.
Most compiler directives in OpenMP uses the Environment Variable OMP_NUM_THREADS to determine the number of threads to create. You can control the number of threads with the following functions;
// Get the number of processors in this system
int iCPU = omp_get_num_procs();
// Now set the number of threads
omp_set_num_threads(iCPU);
Of course, you can put any value for iCPU in the code above (if you do not want to call omp_get_num_procs), and you can call the omp_set_num_threads functions as many times as you like for different parts of your code for maximum control. If omp_set_num_threads is not called, OpenMP will use the OMP_NUM_THREADS Environment Variable.
Parallel for Loop
Let us start with a simple parallel for loop. The following is a code to convert a 32-bit Color (RGBA) image to 8-bit Grayscale image
// pDest is an unsigned char array of size width * height
// pSrc is an unsigned char array of size width * height * 4 (32-bit)
// To avoid floating point operation, all floating point weights in the original
// grayscale formula
// has been changed to integer approximation
// Use pragma for to make a parallel for loop
omp_set_num_threads(threads);
#pragma omp parallel for
for(int z = 0; z < height*width; z++)
{
pDest[z] = (pSrc[z*4+0]*3735 + pSrc[z*4 + 1]*19234+ pSrc[z*4+ 2]*9797)>>15;
}
The #pragma omp parallel for directive will parallelize the for loop according to the number of threads set. The following is the performance gained for a 3264x2488 image on a 1.66GHz Core Duo system (2 Cores).
Thread(s) : 1 Time 0.04081 sec
Thread(s) : 2 Time 0.01906 sec
Thread(s) : 4 Time 0.01940 sec
Thread(s) : 6 Time 0.02133 sec
Thread(s) : 8 Time 0.02029 sec
As you can see, by executing the problem using 2 threads on a dual-core CPU, the time has been cut by half. However, as the number of threads is increased, the performance does not due to increased time to fork and join.
Parallel double for loop
The same problem above (converting color to grayscale) can also be written in a double for loop way. This can be written like this;
Collapse Copy Code
for(int y = 0; y < height; y++)
for(int x = 0; x< width; x++)
pDest[x+y*width] = (pSrc[x*4 + y*4*width + 0]*3735 + pSrc[x*4 + y*4*width + 1]*19234+ pSrc[x*4 + y*4*width + 2]*9797)>>15;
In this case, there are two solutions;
Solution 1
We have made the inner loop parallel using the parallel for directive. As you can see, when 2 threads are being used, the execution time has actually increased! This is because, for every iteration of y, a fork-join operation is performed, and at the end contributed to the increased execution time.
Collapse Copy Code
for(int y = 0; y < height; y++)
{
#pragma omp parallel for
for(int x = 0; x< width; x++)
{
pDest[x+y*width] = (pSrc[x*4 + y*4*width + 0]*3735 + pSrc[x*4 + y*4*width + 1]*19234+ pSrc[x*4 + y*4*width + 2]*9797)>>15;
}
}
Thread(s) : 1 Time 0.04260 sec
Thread(s) : 2 Time 0.05171 sec
Solution 2
Instead of making the inner loop parallel, the outer loop is the better choice. Here, another directive is introduced - the private directive. The private clause directs the compiler to make variables private so multiple copies of a variable does not execute. Here, you can see that the execution time did indeed get reduced by half.
int x = 0;
#pragma omp parallel for private(x)
for(int y = 0; y < height; y++)
{
for(x = 0; x< width; x++)
{
pDest[x+y*width] = (pSrc[x*4 + y*4*width + 0]*3735 + pSrc[x*4 + y*4*width + 1]*19234+ pSrc[x*4 + y*4*width + 2]*9797)>>15;
}
}
Thread(s) : 1 Time 0.04039 sec
Thread(s) : 2 Time 0.02020 sec
Get to know the number of threads
At any time, we can obtain the number of OpenMP threads running by calling the function int omp_get_thread_num(); .
Summary
By using OpenMP, you can gain performance on multi-core systems for free, without much coding other than a line or too. There is no excuse not to use OpenMP. The benefits are there, and the coding is simple.
There are several other OpenMP directives such as firstprivate, critical sections and reductions which are left for another article. :)
Points of Interest
You can also perform similar operation using Windows threads, but it will be more difficult to implement, because of issues such as synchronization and thread management.
Additional Notes
The original RGB to Grayscale formula is given as Y = 0.299 R + 0.587 G + 0.114 B.
References
Parallel Programming in C with MPI and OpenMP (1st ed.) , Michael J. Quinn, McGraw Hill, 2004
Scientific Parallel Computing, L. Ridgway Scott, Terry Clark, Babak Bagheri, Princeton University Press, 2005
Introduction to Parallel Computing: A Practical Guide with Examples in An W.P. Petersen, P. Arbenz, Oxford University Press, 2004.
Digital Color Imaging Handbook, Gaurav Sharma, CRC Press, 2003
피드 구독하기:
글 (Atom)