쓰레드 생성

CJB_ny·2022년 1월 27일
0

Unity_Server

목록 보기
5/55
post-thumbnail

1. 쓰레드 생성과 실행

이제 프로젝트를 킨다음에 쓰레드를 생성을 해보도록 하겠다.


일단 ServerCore를 시작 프로젝트로 설정을 해주고

맨들어보자.

쓰레드를 만드는 것은 굉장히 쉬운데


이렇게 using system.Threading 해주고 하면 된다.

그리고 인자에다가 쓰레드가 실행 된다음에 실행될 일종의 main함수를 넣어 주어야 한다.


일단 간단하게 MainThread라는 함수를 만들어보자

이것이 Thread가 실행되면 맨 처음 실행될 메인 쓰레드 이다.


그래서 이렇게 cw tabtab 으로 헬로 찍어주고 Thread안에다가 넣어주면 된다.


그리고 Main함수안에서 t. 을찍고 Start를 해주면

이제 별도의 쓰레드가 실행이 될것이고.

아까 예제로(예로) 치면은


이 녀석이 (Main(string[] args)가

메인 직원이였고


이녀석은

한명의 직원을 더 고용을 한다음에

걔가 어떤일을 할지


이렇게 지정을 해주고

그다음에

t.Start로 일을 해라! 라고 떠넘긴 셈이다.


이렇게

이상태에서 컨트롤+f5를 눌러보면은


이렇게 뜨는것을 볼 수 있다.

그렇다면 이

쓰레드가 언제 종료되고 소멸이 되냐하냐면은

우리 기본 프로그램과 마찬가지로

이 Main함수가 종료가 되면은 끝난다는 것을 알 수 있다.

그런데 만약

요 안에다가 while


을 이렇게 넣어서 영영 계속 실행이 되게 한다면 어떻게 될까?


Main함수는 t.Start를 하자마자 cw tabtab실행을 하고 바로 종료가 될 것이다.

그런데 MainThread는 계속 실행이 될텐데 흠.. 어케 될까??


실행을 해보면 이렇게

프로그램이 끝나지 않고 영영 계속 돈다는 것을 볼 수 있다.

이게 C++과는 조금 다른 부분이기는 한데

c#에서는 이런 쓰레드를 만들때 기본적으로

for ground thread 로 만들어 지게 된다.


t.을 눌러보면 IsBackground라고 이 녀석을 사용을 해가지고

background에서 실행이 될지 아니면,

background가 아니라 forground라 해가지고(앞에서 실행이 될지)

뒤에서 실행이 될지를 골라줄 것인데

기본적으로는 IsBackground가 == false인 상태이다.


이런 상태인데 이것을 true로 하게 되면은

이렇게 true로 하면은


이녀석은 background에서 실행이 되는 것이니까


만약에 Main이 종료가 되면은


이녀석이 실행 되던 말건

종료를 하게 될 것이다.

이것도 테스를 함 해보면



이렇게 Hello thread를 외치다가


Main Thread가 종료가 되니까


여기 background Thread도 아무런 영향을 끼치지 못하고

종료가 되었다.


지금 Main함수를 Main 쓰레드라고 부르고

IsBackground = true로 해준 부분을 background쓰레드라고 부르는 것 같다


그래서 이 다음으로 해볼것은

2. 쓰레드 대기 Join()

여기다가 던진 쓰레드가 Background쓰레드 이기는 한데


지금 t가 끝날 때 까지

MainThread함수가(background쓰레드가) 기다리고 싶다라고 하면은

어떻게 할지 살펴보도록 하자.


Join이라는 함수가 있는데

(참고로 C++에서도 같은이름으로 되어있다)

그리고 Join을 한다는 것은 무엇이냐 하면은


이녀석이 끝날때까지


여기서 기다렸다가,

끝나고 나면은


이녀석을 출력을 하겠다라는 의미가 되는 것이다.

그래서 이것이 실제로 기다리는 지 확인을 하기 위해서


이렇게 중간에 출력문 넣어주고 실행을 해보자

(내생각에는 계속 while문 돌거같다)


이상태에서 계속 실행은 되는데 종료는 안되고있다.


그러다가 이렇게 일시정지를 시키면


그렇다면 Main Thread같은 경우에는 여기서 일시 정지가 되었다고 보면 될 것이다.

그러면 다른 쓰레드

이쪽 쓰레드는, 다른 쓰레드는 어디서 실행하는지 보고싶으니까

여기 위에 눌러보면은

주 쓰레드가 지금 Main 함수를 뜻하고

190128이 지금 MainThread함수(쓰레드이다)

그래서 < 이름 없을 > 을 눌러보면은

이렇게 되는데, 바로 이쪽으로 이동하는 것을 볼 수 있다.

그러니까 이제는


이 하나의 프로그램 내에서

두개의 쓰레드가 동시에 실행을 하고있는 것이고

아까 식당으로 예를 들면은 직원이 두명이니까

그 직원들이 각각 어떤일을 하고 있는지 보고싶으면


이렇게 왔다갔다 해야된다는 말이 된다.

참고로 지금은 쓰레드가 "이름 없음"으로 되어있는데

이것은 우리가 처음에 지정을 할 수 있다.


t.Name으로 설정을 하고

실행은 한다음에 멈춰서 직원(쓰레드가)이 무슨 일을 하고 있는지 보면은

이름이 나오는데

바꿔준대로


이렇게 잘 뜬다.

이런식으로 어떤 용도에 따라 쓸것인지 구분을 하는 것도 괜찮은 방법이다.

그래서 이렇게 Join을 해가지고 기다리는 것 까지 알아보았다.

3. Thread Pooling

그런데 전에 말했던것 처럼 이렇게

쓰레드를 만드는 것은 굉장히 큰 부담이 되는 작업이라고 했었었다.

사실 우리가 식당을 운영을 할 때에도

인건비가 관건이기는 하다.

그런데 이런식으로 new Thread()를 해주었다는 것은

"정직원"을 하나 추가를 해주었다는 말이 된다.

4대 보험도 들어주고 연봉 계약도 하고 등등..

어쨋든 그직원은 우리가 평생 책임을 져줘야하는 그런 개념이 되는데

그런데 경우에따라서 알바 형식으로 썻다가 그만 쓰고 싶은 경우도 있을 것이다.

예를 들면은


이런식으로 다섯번만 일을 시키고 일을 끝내는 일감을 던져 주었다고

가정을 해보도록 하자.

그런데 그럴 떄마다

이렇게 쓰레드를 만들어가지고

이렇게 일감을 던져주는 것 자체는

너무 어마어마하게 부담이 많이 되니까

그게 아니라 단기알바를 구하면 어떨까? 라는 생각이 들기도 한다.

우리가

이런식으로 쓰레드를 직접 만들어서 관리를 하는 것도 가능은 하지만

이것이 부담이 된다고 하며은

"인력 사무소"에 가가지고

단기알바를 모집하는 것도 나쁘지 않은 방법이다.

그래서 C# 같은 경우에는 쓰레드 풀 이라는 것이 있는데(인력 사무소)

이것을 한번 사용을 해보도록 하자.

ThreadPool

을 이렇게 만들고 . 을 찍어보면은

온갖 기능들이 있는데 ThreadPool은 우리가 생성을 해서 만드는 것이 아니라

.쩜을 찍어서 바로 사용할 수 있다는 것을 볼 수 있다.

일단 static함수로 이루어져있다고 가정을 할 수 있고

직접적으로는 건드릴 수 는 없는데

설정같은 부분을 할 수 있는 것들이 있다.


이렇게 어떤 일감을 던져 줄 것이냐??

SetMinThread == 쓰레드의 갯수는 몇개로 할 것이냐?
GetMinthread등등..

예를들어 단기 알바로 사용을 할 직원수는 SetMinThread로 몇명을 고용을 할 것인지는 SetMinThread로 설정이 가능 하지만

그 50명 하나하나 세세하게 어떻게 일을 분배하고 시킬지는 관리를 할 수 없다는 말이다.

그것은 C#자체에서 .NET FrameWork에서 해주는 부분이고

우리가 할 수 있는 것은

이런식으로 일감을 하나 던지는 것이다.

뭐 "단기알바 대모집"이런식으로 하면서

"콜백"이라는 것을 던져 주는데

우리가 방금전에 만들었던 MainThread함수를 던져 주도록 하자.

그런데 빨간줄이 안 없어지는 것을 볼 수 있는데

void형태로 아무것도 안 받는 것이 아니라 object형태로 뭔가를 받아주어야 한다는 것이다.


그래서 인자로 이런식으로 뭔가를 받아 주기는 해야된다는 뜻이다.

첫인자를 이렇게 object타입으로 받아가지고

이제 안에서 원한다면 캐스팅을 해가지고

그런 방식이 될텐데

그런데 이렇게 바꾸어 주었다고해서 꼭 object state를 사용해야된다는 의미는 아니니까

이렇게해서 드래그 한부분들은

이제 사용을 안할 꺼니가


주석처리하고 이상태에서 실행을 해보도록 하자.

실행을 하면

실행하자마자 바로 종료기 되는 것을 볼 수 있다.

그리고 이렇게 바로 종료가 되었다는 의미는

ThreadPool에 사용하는 애는

아까와 마찬가지고 background로 돌아가는 녀석으로 추측을 할 수 있다.


background로 돌아가니까 바로 종료된다는 말은 =>

background로 돌아가면 Main Thread가 종료가되면

background Thread가 실행되건 말건 바로 종료한다고 했다.

지금 Main이 ThreadPool에 일감을 던져주자마자 종료가되니까

일감을 던져주고 바로 종료가 됨(background Thread 이니까)


그래서 바로 종료가 안되도록

이렇게 while 하나 파주고 실행을 하게되면은


이렇게 5번 실행을 하게된다.

그래서 우리가

단기알바로 고용한 이 아이가 일을 제대로 처리를 했다는 것을 알 수 있다.


그런데 threadPool같은 경우에 우리가 단기 알바라고는 했지만 정확히

어떤 원리에 의해서 돌아가는 지 궁금 할 수 있다.

그것은 어떤 원리냐 하면은

우리가 new Thread를 했을때는 모든 책임을 우리가 맡는다고 했었었다.

그래서 new Thread는 정직원이라는 개념이고

이렇게 ThreadPool을 활용을 하는 경우에는

이녀석은 사실

이런 직원들이 이미 다 마련이 되어있다.

고용한 상태에서(4대 보험까지 다 든 상태에서)

이렇게 대기 중인 상태이고

이렇게 할일을 넘겨주면은

걔가(일을 전달받은 애가)

할일을 찾아가지고 실행을 한다음에

다 끝났다면은

다시 자기의 대기소

에 가가지고

이곳에서 기다른 상태가 되는 것이다.

그래서 어떻게 보면은

필요할 때 마다

직원을 이렇게 매번 고용을 하는 것이 아니라

이렇게 유동적으로

이미 기다리고있는 직원들을 일을 시킬 수 있으니까

좀더 깔끔한 방법이 될 것이다.

그래서 이렇게 사용을 하는 방법을

Thread Pooling 이라고 한다.

그러고 보니까 우리가 Pooling이라는 것을 여러번 들어 보았다!!

Part3에서도 오브젝트 풀링이라고 해가지고

필요할때마다 생성했다가 필요 없으면 날리는 것이 아니라 Pool이라는 대기실에 대기를 시켜놓다가

다시 필요하게되면 꺼내서 사용을 하게 되는 것.

Thread Pooling도 사실 똑같은 개념이라고 보면 된다.

그래서 그냥 ThreadPool은 직원들의 대기 집합소 처럼 생각을 하면 된다.

4. Thread, ThreadPool 차이점

Thread, Thread Pool을 사용을 할때 또 중요한 차이점이 있기는 한데

Thread같은 경우에는 딱히 갯수를 제한 하지 않았으니까

우리가 원하는대로 1000개를 만들어도 똑같이 잘 실행이 될 것이다.

예를 들면은


이렇게 1000개를 만들어서 실행을 해보면은


굉장히 힘들게 힘들게 뭔가를 실행을 하고 있지만은 언젠가는 일을 다 끝내게 될 것이다.

그래서 과연 직원을 천명을 고용하는 것이 현명한지는 모르겠지만

어쨋든

우리가 이렇게 "직접" 고용을 하니까 가능은 하다는 의미가 되고

그런데 우리가 "개론"시간에서 얘기했던 부분을 살펴 보면은

"직원"(쓰레드)를 천명 고용한다고해서 효율이 천배 좋아지는 것은 당연히

아니라고했었다.

애당초 사실은 가장 중요한 것은

실제 이 쓰레드들이 실행되고 있는 것이 중요한데

이렇게 실행된다는 것은

곧 CPU코어랑 밀접한 연관이 있다고 했다.

CPU코어가 8개라고 하면은

아무리


이녀석이 용을 쓴다고 하더라도

실제로 동시에 실행이 되는 쓰레드 갯수는 8개 일 것이다.

그래서 효율성을 높으려면 쓰레드 갯수랑 CPU갯수를 맞춰 주는게 좋다고했었다!!

그런데

이런식으로 Thread갯수가 너무 많아 진다고 하면은

이렇게 각 쓰레드를 실행시키는 작업보다

쓰레드끼리

왔다갔다 하면서 코어가 어떤 쓰레드를 빙의 시켰다가 다음 쓰레드를 빙의 했다가

왔다갔다 거리는 작업이

이 쓰레드를 일을 시키는 작업보다 시간이 더 걸린다라는 문제가 있다.

이렇게 되면 진짜 "최악"의 경우가 된다.

사실 중요한것은


이녀석이 일을 하는 것이 중요한 것이지

왔다 갔다하면서 최대한 동시에 실행되는 것처럼 보이게 하는게 중요한것이 아니다!

그런데

for문 처럼 엄청나게 많은 쓰레드들을 만들게 되면은

부화가 점점 커지니까

이 방법은 굉장히 문제가 되는 방법이다.

그래서

이런식으로 Thread를 마음대로 늘리는 것은 최악의 경우라고 볼 수 있다.

반면,

이렇게 ThreadPool을 사용하는 방법은

일단은 QueueUserWorkItem 이녀석이 최대한 으로 돌릴 수 있는

Thread를 제한을 해놓기 때문에

우리가 for문에서 처럼 1000개씩 실행을 해달라고 요구를 하면은,

QueueUserWorkItem이녀석이 바로 실행이 되는 것이 아니라

기존에 일을 하던 알바들이

이 일을 끝내고 돌아 올때,

걔내들을 다시 재투입을 한다.

그래서 정말 어떻게 보면은 "인력 사무소" 느낌이다.

QueueUserWorkItem 이러한 인력 사무소에서 인력을 10명이라고 고정을 했다고 하면은

10명이 일을 나갔을 때 새로 인력을 봅아서 일감을 맡길 수 있는 것이 아니라

기존에 던진 인력들이 돌아 와가지고

다시 "기다리는" 상태가 되어야지만

다음 일감을 받을 수 있다는 차이점이 있다.

그런데 이것이 굉장히 좋은 것일 수도 있지만,

사실 이게 나쁜 것일 수도있다.

왜냐하면

ThreadPool에 일을 맡겼는데 그 일이 굉장히 짧은 거여가지고

동작을 한다면

QueueUserWorkItem를 쓰는 것이 굉장히 아름다운 방법이 되겠지만은

그게 아니라 아까와 마찬가지로


이런식으로 무한대로 잡아버리고 있다라고 하면은


이녀석이 일을 투입시키고 나면은

인력들이 영영 돌아오지 않는 다는 얘기가 된다.

그렇다면

언젠가는

QueueUserWorkItem

이녀석은 인력이 부족해가지고

여기다가 다른 단기 알바를 요청을 하더라도

더이상 인력(알바)가 없어서 실행을 하지 못하는 상황이 벌어 질 것이다.

그래서 ThreadPool을 사용을 할때는 가급적이면 짧은 일감을 사용을 할때 던져주는 것이 좋은데

이런식으로 일감이 엄청 길어지는 일감들을 많이 던져주게 되면은


이녀석이 통째로 먹통이 될 위험이 있다라는 점까지 알아 보았다.

5. ThraedPool 먹통

그 다음에 이제 ThreadPool먹통 실습을 해보도록 하자.

사진처럼

SetMinThread를 하게되면

workerThread랑 completionThread라는 개념이 나오는데

우리는 일단 전자에 더 관심이 많은 상태이다.

우선 첫번째 인자에 넣어준것은

이 일을 하게 될 아이를 넣어준 것이 되고

두번째 인자는 일단 간단히 스킵을 하고 넘어가도록 한다.

나중에 IO오 관련된 input, output과 관련된

네트워크와 관련된 작업에 관련된 쓰레드의 갯수와 관련된 쓰레드의 갯수를 설정하는 인자이다.

지금 테스트에서는 그런 큰 의미는 없다.

그리고

SetMaxThreads라는 것이 있는데 5, 5로 맞춰 주도록 한다.

그러니까 우리가 최소는 1개이고 최대는 5개로 설정을 해준 셈이 된다.

그러니까 5개의 일감을 던져주면은

설령 다음 일감이 요청되어서 오더라도

실행이 안된다는 얘기가 되는 것이다.

그래서

이렇게 5개의 일감을 받도록 만들어주는데 일단

람다 식으로 만들어 주도록 하자.

object를 받아가지고 무엇무엇을 실행(만드는 것을)실행해 주세요인데

이 무엇무엇 안에서는


이렇게 무한 루프를 돌도록 하게하고 놔주지않는

끔찍한 일을 실행을 해보도록 하자.

그러면

이렇게 하면

영영 돌아올 수 없는 일감을 시킨 거니까

그 다음 녀석이

이렇게 뭔가를
이부분을 요청을 해서

5개짜리 Hello Thread를 요청을 하더라도

우리가 아까 본 바와 맞다라면은

지금 이녀석이 먹통이 된다는 의미가 될 것이다.

그리고 실제로 그런지 실행을 해보면은

아무것도 실행이 되지 않는 것을 볼 수 있다.

그러니까

이부분이 실행이 안됬다는 것은 결국

이녀석이 모든 인력들을 다 뺴가서 그렇다라는 것을 볼 수 있고

그런데 혹시라도 for(int i = 0; i < 5; 가 아니라 i < 4; 여가지고

일꾼이 하나 ThreadPool에 남는 상태라고 가정을 하면은

Hello Thread가 제대로 출력이 되어야 할 것이다.


그러면 cw tabtab으로 만들어 주고 실행을 해보면 잘된다.

만약 i < 5; 로 설정을 해서 돌리게 되면

이렇게 일꾼이 부족해서 실행을 못한 채로 종료가된다.

그래서 결국에는 ThreadPool이 좋기는 한데

누군가가 이렇게 오래 붙잡아 둔다면은

시스템이 먹통이 될 위함이 있다는 얘기가 된다는 것이다.

그런데 이런 단점을 극복할 만한

방법이 있다.

자, 그 방법이라 하믄 세번째 옵션인

Task를 이용하는 방법이 될 것이다.

빨간줄이 뜨는데

마찬가지로

using system.Threading.Task해주면됨!

오늘 용어가 좀 많이 등장을 하는데

  • Thread
  • ThreadPool
  • Task

Task까지 등장을 했는데

일단은 이렇게 크게 세게만 알고 있으면 될거같다.

Task는 직원을 고용하는 것 보다는

그 직원이 할 일감단위를

우리가 정의 해가지고 뭔가 사용하겠다라는 의미에 더 가깝다.

그런데 Task는 인터페이스가 굉장히 많은데

Thread를 사용할때 처럼 인터페이스를 유사하게 맞춰 주자면은

얘도 마찬가지고 new Task를 해준다음에

실행하게될(실행해야할 함수를) 함수를 넣어주게 될 텐데


이렇게 아까와 같이 넣어 주도록 하자

그런데 빨간 줄이 지금 뜨고있는데

Action이니까


아무런 인자를 안 받는 식으로 넣어 주도록 하자.


그리고 아까와 마찬가지고

t.start를 하게돠면 아까와 마찬가지고 시작을 하게되는 것이고

그런데 Task의 경우에는 Thread와는 조금 다른것이

이녀석도
이 ThreadPool에 넣어서 관리가 될 것이다.

그런데

threadPool에다가

이런식으로 직접 QueueUserWorkItem를 넣어서 일을 던져주는 것과는 달리,

new Task(() => { while(true) {} }, 이부분에다가 옵션을 하나 더 넣어줄 수 있는데


여기

TaskCreationOptions 에 가가지고

LongRunning이라는 애를 입력을 하게 되면은


여기 threadPool에 들어가기는 하는데


애당초 t 이녀석은 애당초 엄청 오래 걸릴 것이다!(LongRunning) 라는 녀석이라고,

알려주는 것이다.

그러면 이녀석은

workerThreadPool에서 뽑아서 실행하는 것이 아니라

애당초

        Task t = new Task(() => { while (true) { } }, TaskCreationOptions.LongRunning );

이녀석은 별도 처리를 하게 되는 기능을 하는 것이 될것입니다.

그러니까

        Task t = new Task(() => { while (true) { } }, TaskCreationOptions.LongRunning);

이녀석은

Thread와 ThreadPool의 양쪽 장점을 이용하는 셈이 되는 것이다.

그래서 이것을 테스트 해보기 위해서 마찬가지고 5개짜리로 만들어 주고


아래 부분 주석 처리를 해주고

LongRunning이라고 해주게 되었을때도 불구하고


우리가 여기 첫인자로 넣어준 workerThread를 소모를 하는 녀석이였다고 한다면은


이부분이 먹통이 돠어야 정상이겠지만

우리는 LongRunning이라는 녀석을 사용을 했으니까

실행을 해보면은

일단은 나는 바로 꺼진다

뭐가 문제인지 모르겠다

이부분은 질문글을 남겼으니 일단 넘어가도록 하자.

원래는 정상적으로 Hello thread가 떠야 정상이다!

그래서 이런식으로 굉장히 오래 걸리는 일은

LongRunning이라고 인자를 하나 추가를 해줘서 넣어 줄 수 있을 것이고

만약 LongRunningTask인자를 안 넣었다라면은


이상태로 실행을 해보면은

나는 일단

이렇게 먼저뜨고 예를 누르면


그냥 이렇게 뜬다....

ㅅㅂ..

두번째 인자에 LongRunning을 안넣어주게 되면

workerThread의 5마리를 다 추출을 해서 사용을 하게 된 것이고

그래서 어쨋든

이녀석은 굉장히 유연하게

오래 사용할 것인지 아닌지에 따라가지고

ThreadPool을 조금더 효율적으로 관리를 할 수 있게 된다는 말이 된다.

그런데 아까 말한것처럼

C#에서는 이렇게 Thread를 직접 관리를 할 일이 진짜 거의 없다고 보면은 된다.

웬만해서는 이 ThreadPool에서 제공하는 기능들을

최대한 활용하면 좋겠고

마찬 가지로 오래 걸리는 일이라고 가정을 하면은

굳이 Thread를 만들지않고

Task를 만들어서 실행을 해도 충분하는 얘기가 된다는 말이다.

그래서 여기까지가

  • Thread

  • ThreadPool

  • Task

에 관한 이야기 였고

어쩃든 이렇게해서 우리는 "직원"들을 고용을 하고

근데 정직원이 아니라 알바도 고용할 수 있고

그리고 조금더 재사용성을 편리하게 하기 위해서

인력풀(ThreadPool)에서 인력사무소에가서 인력을 고용하는 그런 작업까지

둘러본 것이 된다.


전체 코드

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
class Program
{
static void MainThread(object state)
{
for (int i = 0; i < 5; i++)
Console.WriteLine("Hello Thread!!");
}

    static void Main(string[] args)
    {
        ThreadPool.SetMinThreads(1, 1);
        ThreadPool.SetMaxThreads(5, 5);

        for(int i = 0; i < 5; i++)
        {
            Task t = new Task(() => { while (true) {  } } , TaskCreationOptions.LongRunning);
            t.Start();
        }

        ThreadPool.QueueUserWorkItem(MainThread);

        while (true)
        {

        }

        //for (int i = 0; i < 1000; i++)
        //{
        //    Thread t = new Thread(MainThread);
        //    t.Name = "Test Thread";
        //    t.IsBackground = true;
        //    t.Start();

        //}

        //Console.WriteLine("Wait for Join!");
        //t.Join();

        //Console.WriteLine("Hello!");
    }
}

}


질문 했던거

문제점이 내 코드안에서 Main함수 안에서 Isbackground가 false가 default값이기 때문에

Main함수가 종료가 되면은 다 종료를 해버리기 때문에

Main이 종료가 되지 않도록 밑에

while(true) { } 로 Main이 종료가 안되도록 붙잡아 두지 않아서 이렇게 질문글을 남겨두었던 것이다.

실제로 while(true)로 붙잡아두면 강의 처럼 잘 된다.


profile
https://cjbworld.tistory.com/ <- 이사중

0개의 댓글