6.4 컬렉션

정해지지 않은 크기의배열

6.4.1 System.Collections.ArrayList

 

ArrayList는 object 타입 및 그와 형 변환 가능한 모든 타입을 인자로 받아 컬렉션에 추가/삭제/변경/조회가 가능한 기능을 구현 한 타입. (크기가 자유롭게 변할 수 있는 타입)

using System.Collections;

ArrayList ar = new();
ar.Add("hello");
ar.Add(6);
ar.Add(true);

Console.WriteLine($"Contatins(6): {ar.Contains(6)}");
ar.Remove("hello");
ar[1] = false;
Console.WriteLine();
foreach(object obj in ar)
{
    Console.WriteLine(obj);
}

#출력
Contatins(6): True

6
False

 

 ArrayList는 object를 인자로 갖기 때문에 닷넷의 모든 타입을 담을 수 있는 장점이 있지만, 박싱이 발생하는 단점이 있다. 따라서 System.ValueType을 상속받는 값 형식을 위한 컬렉션으로는 적당하지 않다. 이를 위해 닷넷 2.0부터지원되는 제네릭이 적용된 List<T> 타입을 사용하는 것을 권장한다.

더보기
  1. ArrayList는 object 타입을 인자로 받습니다. 즉, 어떤 타입의 값이든 다 담을 수 있는 컬렉션입니다. 하지만 object는 모든 타입의 상위 클래스이기 때문에, 값 형식(value type) (예: int, double, struct 등)을 넣을 때 박싱(boxing) 이 발생합니다. 박싱은 값 형식을 object로 변환하는 과정인데, 이 과정에서 성능 저하가 발생할 수 있습니다.
  2. 박싱: 예를 들어, int 값을 object로 담을 때, 실제로는 int 값을 객체 형태로 변환해야 하므로 추가적인 메모리 할당과 시간이 소요됩니다. 이로 인해 성능에 영향을 미칠 수 있습니다.
  3. 그래서 **값 형식(value type)**을 다루는 컬렉션에 ArrayList를 사용하는 것은 적합하지 않다고 합니다.
  4. **List<T>**는 제네릭(Generic) 컬렉션으로, T는 특정 타입을 나타냅니다. 예를 들어, List<int>는 int만 담을 수 있습니다. 이 방식은 타입이 고정되어 있기 때문에 박싱이 발생하지 않습니다. 따라서 int 같은 값 형식을 다룰 때는 **List<T>**를 사용하는 것이 더 효율적이고, 성능 측면에서 유리합니다.

결론: 값 형식을 다루기 위해서는 **List<T>**를 사용하는 것이 좋으며, ArrayList는 값 형식을 다룰 때 박싱 문제가 있기 때문에 권장되지 않는다는 내용입니다.

 

ArrayList는 언제 사용되나?

 

ArrayList는 주로 값 형식참조 형식을 모두 혼합해서 담고자 할 때 유용합니다. 예를 들어, 다양한 타입의 객체를 모두 담고 싶을 때 ArrayList를 사용할 수 있습니다. 하지만 그럴 경우, 값 형식은 박싱이 발생하고 참조 형식은 박싱이 발생하지 않기 때문에 성능에 영향을 미칠 수 있다는 점을 고려해야 합니다.

구체적으로는 다음과 같은 경우에 ArrayList를 사용할 수 있습니다:

  1. 다양한 타입의 객체를 담아야 할 때
    ArrayList는 object를 상속받은 모든 타입을 담을 수 있기 때문에, 여러 종류의 객체를 하나의 컬렉션에 담아야 하는 상황에서 유용합니다. 예를 들어, 같은 컬렉션에 int, string, MyClass 같은 서로 다른 타입의 값을 넣어야 할 때 사용합니다.
  2. 타입이 동적으로 결정될 때
    컬렉션에 담을 객체의 타입이 컴파일 타임에 결정되지 않고, 실행 중에 동적으로 결정될 경우에도 ArrayList를 사용할 수 있습니다.
  3. 기존 코드와의 호환성
    ArrayList는 .NET 1.0부터 존재한 클래스이므로, 이전에 작성된 코드와의 호환성 때문에 여전히 사용될 수 있습니다. 하지만 .NET 2.0 이후로는 **List<T>**가 제네릭 컬렉션으로 훨씬 더 권장되기 때문에, 새로운 코드에서는 List<T>를 사용하는 것이 좋습니다.
  4. 주의 사항: ArrayList는 제네릭을 사용하지 않기 때문에 **타입 안전성(type safety)**을 보장하지 않습니다. 즉, 잘못된 타입의 객체를 담을 수 있기 때문에, 잘못된 타입을 추가하면 런타임 오류가 발생할 수 있습니다. 예를 들어, ArrayList에 int와 string을 혼합하여 담았다가 꺼낼 때 타입을 잘못 추측하면 오류가 발생할 수 있습니다.
ArrayList list = new ArrayList();
list.Add(5);
list.Add("Hello");

int number = (int)list[1];  // 예외 발생: "Hello"는 int로 캐스팅할 수 없음

이러한 이유로, 값 형식을 처리해야 하거나 타입 안전성이 중요한 경우, **List<T>**를 사용하는 것이 더 안전하고 효율적입니다.

 

ArrayList는 요소를 정렬 할 수 있는 메서드도 제공한다. Sort 메서드를 제공하지만 요소가 모두 같은 타입이어야 한다. 그렇지 않을 경우 ArgumentException 예외가 발생된다.

using System.Collections;

ArrayList arr = new();
arr.Add("a");
arr.Add("b");
arr.Add("z");
arr.Add("va");
arr.Add("vb");
arr.Add("w");
arr.Add("c");
arr.Sort();
foreach(string str in arr)
{
    Console.WriteLine(str);
}

#출력
a
b
c
va
vb
w
z

 

사용자 정의 타입을 요소로 가지고 있을 경우 Sort는 Icomparer 인터페이스를 구현한 타입의 객체를 Sort 메서드의 2번째 인자로 전달하면 된다.

using System;
using System.Collections;

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

class PersonComparer : IComparer
{
    // 이름을 기준으로 오름차순 정렬
    public int Compare(object x, object y)
    {
        Person personX = (Person)x;
        Person personY = (Person)y;

        return string.Compare(personX.Name, personY.Name);
    }
}

class Program
{
    static void Main()
    {
        // ArrayList에 Person 객체 추가
        ArrayList people = new ArrayList
        {
            new Person("John", 25),
            new Person("Alice", 30),
            new Person("Bob", 22)
        };

        // PersonComparer를 사용하여 이름을 기준으로 정렬
        people.Sort(new PersonComparer());

        // 정렬된 결과 출력
        foreach (Person person in people)
        {
            Console.WriteLine($"{person.Name} - {person.Age}");
        }
    }
}

 

 

이번엔 IComaparable 인터페이스를 이용해서 정렬작업을 수행하자.

 

예제 6.13 사용자 정의 클래스에 정렬 기능 추가.

using System.Collections;

ArrayList ar = new();
ar.Add(new Person(32, "Cooper"));
ar.Add(new Person(56, "Anderson"));
ar.Add(new Person(17, "Sammy"));
ar.Add(new Person(27, "Paul"));

ar.Sort();
foreach(Person person in ar)
{
    Console.WriteLine(person);
}
public class Person : IComparable
{
    public int Age;
    public string Name;
    public Person(int age, string name)
    {
        Age = age;
        Name = name;
    }
    public int CompareTo(object? obj) //나이 순서로 정렬
    {
        Person target = (Person)obj;
        if (this.Age > target.Age) return 1;
        else if (this.Age == target.Age) return 0;
        return -1;
    }

    public override string ToString()
    {
        return string.Format($"{this.Name}: {this.Age}");
    }
}

#출력
Sammy: 17
Paul: 27
Cooper: 32
Anderson: 56

 

6.4.2 System.Collections.Hashtable

해당 컬렉션은 값(value) 뿐만 아니라 해시에 사용되는 키(key)가 추가되어 빠른 검색 속도를 가진다.

검색속도의 중요도에 따라 ArrayList 또는 Hashtable 중 어느 것을 선택할지 결정한다.

 

Remove 메서드를 통한 속도 비교

ArrayList.Remove

1. 0번째 요소 값과 Remove 인자의 값을 비교. 같을 시 삭제 하고 return 문 수행.

2. 1번째 단계에서 값을 찾지 못하면 그 다음 요소의 값과 비교. 값이 같으면 삭제하고return 문 수행

3. 값을 찾을 때까지 2번 단계 동작 반복. 값이 ArrayList에 존재하지 않는 경우 전체 요소의 값을 열람할 수 밖에 없다.

 

Hashtable.Remove 

1. Remove 인자로 들어온 Key 값을 해시(hash)한다. 예를 들어, "key1" 문자열이 키 값인 경우 "key1".GetHashCode() 메서드가 호출됨

2. GetHashCode 메서드 호출의 결괏값은 정수다. 그 정수는 내부 데이터 저장소에 대해 곧바로 접근할 수있는 인덱스로 사용된다. 따라서 값을 검색하는 과정 없이 곧바로 저장된 값의 위치를 알 수 있다.

3. 해시에 해당하는 위치에 동일한 값 있다면 삭제하고, 없다면 더는 추가 동작을 수행하지 않고 메서드 실행을 마친다.

 

Remove에 대한 검색 과정 빅오표기법

ArrayList : O(N)

Hashtable : O(1)

 

using System.Collections;

Hashtable ht = new();

ht.Add("key1", "add");
ht.Add("key2", "remove");
ht.Add("key3", "update");
ht.Add("key4", "search");
ht.Add(1, "se");
Console.WriteLine(ht["key4"]);
ht.Remove("key3");
ht["key2"] = "delete";

foreach(object key in ht.Keys)
{
    Console.WriteLine($"{key} ==> {ht[key]}");
}


#출력
search
key4 ==> search
key1 ==> add
1 ==> se
key2 ==> delete

Hashtable을 사용 시 한가지 주의할 점은 키 값 중복되는 경우 Add 메서드에서 ArgumentException  예외가 발생하니 중복키에 주의하자.

Hashtable은 ArrayList와 달리 키 값도 내부적으로 보관하고 있기에 그 만큼의 메모리가 낭비된다.

게다가 키와 값이 모두 object 타입으로 다뤄지기에 Hashtable에서도 박싱 문제가 발생한다.

 

또한 Add된 요소 순서도 보장되지는 않는다. 그래서 출력 때마다 순서가 달리 나옴

 

6.4.3 System.Collections.SortedList

SortedList는 Hashtable 타입과 사용법이 유사하다. Hashtable에서는 키가 해시되어 데이터를 가리키는 인덱스 용도로 사용했지만, SortedList의 키는 그 자체가 정렬되어 값의 순서에 영향을 준다.

 

예제 6.13에서 Person 객체를 사용하지 않고  Age를 키, Name을 값으로 다음과 같이 사용할 수 있다.

using System.Collections;
using System.Data;

SortedList sl = new();

sl.Add(32, "Cooper");
sl.Add(56, "Anderson");
sl.Add(17, "Sam");
sl.Add(27, "Paule");

foreach(int key in sl.GetKeyList())
{
    Console.WriteLine(string.Format($"{key}: {sl[key]}"));
}

#출력
17: Sam
27: Paule
32: Cooper
56: Anderson

 

SortedList는 Sort메서드 호출할 필요 없이 키 자체가 정렬된다. 그러나 키 값이 중복될 경우 Hashtable처럼 예외가 발생한다.

 

6.4.4 System.Collections.Stack

using System.Collections;

Stack st = new();
st.Push(1);
st.Push(2);
st.Push("Aaa");
st.Push(3);
int last = (int)st.Pop();
while (st.Count > 0) 
{
    Console.WriteLine(st.Pop());
}
if(st.Count > 0)st.Clear();  // 현재 있는 모든 요소 지움

#출력
Aaa
2
1

Stack도  object를 인자로 다루기에 박싱문제가 발생됨.

6.4.5 System.Collections.Queue

해당 큐 bcl은 Enqueue(선입 삽입, 데이터삽입)와 Dequeue(선입 후삭, 데이터꺼내기)를 지원한다.

using System.Collections;

Queue q = new();
q.Enqueue(1);
q.Enqueue(2);
q.Enqueue(3);
q.Enqueue('a');

int first = (int)q.Dequeue();
q.Enqueue(7);

while(q.Count > 0)
{
    Console.WriteLine(q.Dequeue());
}

#출력
2
3
a
7

 

Queue 타입 역시 object를 인자로 다루기에 박싱 문제가 발생한다.

 

6.5 파일

6.5.1 System.IO.File.Stream

FileStream은 파일을 다루기 위한 BCL의 가장 기본적인 타입이다.

MemoryStream의 부모클래스와 동일한 Stream 타입을 FileStream이 상속 받았고 전체적인 동작 방식도 MemoryStream과 유사하나 다른 점은 MemoryStream은 메모리에 할당한 바이트 배열을 대상으로 읽기/쓰기 작업을 했지만, FileStream은 디스크의 파일을 대상으로 읽기/쓰기 작업을 한다.

 

6.3절 '직렬화/역직렬화'에서 다룬 MemoryStream을 FileStream과 교체해서 실행이 가능하다.

 

예제 6.16 FileStream에 텍스트를 쓰는 예제

using System.Text;

using (FileStream fs = new FileStream("test.log", FileMode.Create))
{
    StreamWriter sw = new StreamWriter(fs, Encoding.UTF8);
    sw.WriteLine("Hello World");
    sw.WriteLine("Anderson");
    sw.Write(32000);
    sw.Flush();
}

출력

 

 

예제 6.16-2 BinaryWriter를 사용 한 예제

using System.Text;

using (FileStream fs = new FileStream("test2.log", FileMode.Create))
{
    BinaryWriter bw = new BinaryWriter(fs, Encoding.UTF8);
    bw.Write("Hello World" + Environment.NewLine);
    bw.Write("Anderson" + Environment.NewLine);
    bw.Write(32000);
    bw.Flush();

}

출력

 

전자와는 달리 32000이 공백과 }문자로 출력된다. 표 6.6의 바이트 내용에서 2진 데이터로 출력된 '00 7D 00 00' 부분을 문자열로 취급하기 때문에 발생한 현상이다.

 

FileStream을 이용해 파일을 읽고 쓸 때 사람이 읽을 수 있게 하려면 StreamWriter를 사용하고, 가독성을 무시하고 효율적으로 데이터를 기록하려면 BinaryWriter를 사용한다.

 

FileStream에 대한생성자 인자로는 FileMode, FileAccess, FileShare가 있으며 이 세가지 모두 열거형이며, 각 인자의 사용법은 다음과 같다.

 

표 6.7 FileMode

열거형 값 설명
CreateNew 파일을 항상 새롭게 생성. 같은 이름의 파일 존재시 IOException 예외 발생
Create 파일을 무조건생성. 같은 파일 이미 있을 시 기존 데이터가 모두 삭제됨 
Open 이미 있는 파일을 연다. 만약 지정된 이름의 파일 존재하지 않을 시 FileNotFoundException 예외 발생
OpenOrCreate 같은 이름의 파일이 이미 있다면 열고, 없다면 생성
Truncate 이미 있는 파일을 열고 기존 데이터는 모두 삭제. 같은 이름의 파일이 존재하지 않는다면 FileNotFoundException 예외 발생
Append 파일을 무조건 연다. 같은 이름의 파일이 있다면 FileStream의 Position 값을 마지막 위치로 자동으로 이동 시킨다. 같은 이름의 파일이 없다면 새롭게 생성한다.

표 6.8 FileAccess

열거형값  설명
Read 파일을 읽기 목적으로 연다
Write 파일을 쓰기 목적으로 연다
ReadWrite 파일을 읽기 쓰기 목적으로 연다. == FileAcess.Read | FileAccess.Write

 

표 6.9 FileShare 

열거형 값 설명
None 같은 파일에 대해 두 번 이상 여는 경우 무조건 실패. 즉 맨 처음 파일 열고 있는 FileStream만이 해당 파일을 사용할 수 있다.
Read 같은 파일에 대해 FileAccess.Read로 여는 것 만 허용. 맨 처음 파일을 여는 FileStream은 모든 동작을 할 수 있지만, 이후에 그 파일을 열러는 시도는 오직 읽기 모드로만 허용.
Write 같은 파일에 대해 FileAccess.Read로 여는 것 만 허용. 맨 처음 파일을 여는 FileStream은 모든 동작을 할 수 있지만, 이후에 그 파일을 열러는 시도는 오직 쓰기 모드로만 허용.
ReadWrite 같은 파일에 대해 File.Access.Read 또는 FileAccess.Write 또는 그 두가지 모두 지정된 목적으로 여는 것을 허용. 즉, 같은 파일에 대해 서로 다른 FileStream에서 읽고 쓰는 것이 가능.

 

 

표 6.10 자주 사용되는 옵션 조합

옵션 조합 설명
FileMode.Append 로깅 목적의 파일쓰기를 하는 경우 사용
(FileMode.Append인 경우 FileAccess는 Write만 허용, FileShare의 기본 값은 Read이므로 굳이 지정할 필요 x)
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None
재사용되는 전용 데이터를 입/출력하는 목적인 경우 사용
FileMode.Create,
FileAccess.ReadWrite,
FileShare.None
임시로 사용되는 데이터를 입/출력하는 목적인 경우 사용

 

1. 로깅 목적의 파일 열기 (추가 모드, FileMode.Append)

using System.IO;

class Program
{
    static void Main()
    {
        // 로깅 목적의 파일 열기 (추가 모드)
        using (FileStream fs = new FileStream("log.txt", FileMode.Append))
        {
            // 파일에 쓰기 작업 (예: 로깅)
            byte[] logData = System.Text.Encoding.UTF8.GetBytes("New log entry\n");
            fs.Write(logData, 0, logData.Length);
        }
    }
}

 

  • FileMode.Append: 파일이 존재하면 파일 끝에 데이터를 추가합니다. 파일이 없으면 새로 생성됩니다.
  • FileStream은 파일 스트림을 열어 데이터를 쓸 수 있습니다.

2. 전용 데이터 입출력 (읽기/쓰기 모드, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)

파일에 대한 읽기 및 쓰기 작업을 하며, 다른 프로세스는 해당 파일을 열 수 없도록 설정합니다.

using System.IO;

class Program
{
    static void Main()
    {
        // 전용 데이터 입출력
        using (FileStream fs = new FileStream("data.txt", FileMode.OpenOrCreate, 
                                              FileAccess.ReadWrite, FileShare.None))
        {
            // 읽기/쓰기 작업
            byte[] data = new byte[fs.Length];
            fs.Read(data, 0, data.Length);
            string content = System.Text.Encoding.UTF8.GetString(data);
            Console.WriteLine("File Content: " + content);

            // 파일에 새 데이터 쓰기
            byte[] newData = System.Text.Encoding.UTF8.GetBytes("Appended new data\n");
            fs.Seek(0, SeekOrigin.End);  // 파일 끝으로 이동
            fs.Write(newData, 0, newData.Length);
        }
    }
}

 

 

  • FileMode.OpenOrCreate: 파일이 존재하면 열고, 파일이 없으면 새로 생성합니다.
  • FileAccess.ReadWrite: 파일을 읽고 쓸 수 있도록 설정합니다.
  • FileShare.None: 다른 프로세스가 파일을 열 수 없도록 제한합니다.

3. 임시 데이터 입출력 (새 파일 생성, FileMode.Create, FileAccess.ReadWrite, FileShare.None)

파일을 새로 생성하여 데이터를 읽고 쓸 수 있게 설정합니다.

using System.IO;

class Program
{
    static void Main()
    {
        // 임시 데이터 입출력 (새 파일 생성)
        using (FileStream fs = new FileStream("tempData.txt", FileMode.Create, 
                                              FileAccess.ReadWrite, FileShare.None))
        {
            // 읽기/쓰기 작업
            byte[] dataToWrite = System.Text.Encoding.UTF8.GetBytes("Temporary data written\n");
            fs.Write(dataToWrite, 0, dataToWrite.Length);

            // 파일 내용을 읽어오기
            fs.Seek(0, SeekOrigin.Begin);  // 파일의 처음으로 이동
            byte[] readData = new byte[fs.Length];
            fs.Read(readData, 0, readData.Length);
            string content = System.Text.Encoding.UTF8.GetString(readData);
            Console.WriteLine("File Content: " + content);
        }
    }
}

 

 

  • FileMode.Create: 파일이 존재하면 덮어쓰고, 없으면 새로 생성합니다.
  • FileAccess.ReadWrite: 읽기 및 쓰기 작업을 허용합니다.
  • FileShare.None: 다른 프로세스가 파일을 열 수 없도록 합니다. 

결론:

  • 로깅 목적: 파일 끝에 데이터를 추가할 때 사용 (Append).
  • 전용 데이터 입출력: 파일을 열고 다른 프로세스에서 파일을 사용하지 못하게 하고 읽기/쓰기를 하는 경우.
  • 임시 데이터 입출력: 새 파일을 생성하고 데이터를 읽고 쓰는 경우.

 

using System.Text;

using (FileStream fs = new FileStream("test.log", FileMode.Append, FileAccess.Write, FileShare.None))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
    sw.WriteLine("Hello World");
    Console.ReadLine();
}

 

콘솔 입력 전 메모장 열 시 정상적으로 열린다.

using System.Text;

using (FileStream fs = new FileStream("test.log", FileMode.Append, FileAccess.Write, FileShare.None))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
    sw.WriteLine("Hello World");
    Console.ReadLine();
}

이번에는 메모장을 열면 파일을 열수 없는 오류 메시지가 나타난다. 파일 로드시 FileAccess.Read모드로 열기에 공유를 허용하지 않는 FileShare.None과 출동이 발생해서이다.

 

 

using System.Text;

using (FileStream fs = new FileStream(Environment.CurrentDirectory + "test.log", FileMode.Append, FileAccess.Write, FileShare.None))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
    sw.WriteLine("Hello World");
    Console.ReadLine();
}

기본 경로 표시 : Environment.CurrentDirectory

 

using System.Text;

Environment.CurrentDirectory = "C:\\temp";
using (FileStream fs = new FileStream("test.log", FileMode.Create))
{
    sw.WriteLine("Hello World");
    Console.ReadLine();
}

test.log 파일은c:\temp 폴더에 생성된다.

 기본 경로를 미리 설정시 해당 경로는 FileStream 뿐만 아니라 경로를 다루는 모든 클래스에 적용된다.

 

6.5.2 System.IO.File / System.IO.FileInfo

File 타입은 자주 사용되는 파일 조작 기능을 담은 정적 클래스다. File 타입에서 제공되는 모든 메서드는 정적 메서드다. 

 

표 6.11 File 타입의 정적 메서드

 

정적 메서드 설명
Copy 파일을 복사한다.
Exists 파일이 존재하는지 여부를 true/false로 반환
Move 파일을 이동한다.
ReadAllBytes 파일의 모든 내용을 읽어 byte 배열로 반환한다.
ReadAllLines 텍스트 파일의 모든 내용을 string 배열로 반환. 한 줄당 문자열 하나로 대응된다.
ReadAllText 텍스트 파일의 모든 내용을 읽어 string 객체로 반환
WriteAllBytes 지정된 byte 배열을 모두 파일에 쓴다
WriteAllLines 지정된 string 배열의 모든 내용을 개행 문자와 함꼐 파일에 쓴다.
WriteAllText 지정된 string 인자의 값을 모두 파일에 쓴다.

 

File.Copy 메서드는 복사되는 위치에 이미 파일이 있으면 IOException이 발생한다. 만약 덮어쓰고자 하면 3개의 인자를 갖는 Copy메서드를 사용하자.

// 경로가 지정되지 않으면 Environment.CurrentDirectory가 기본 경로가 사용됨.
// 대상 폴더에 파일이 없다면
File.Copy("test.log", "test.dat");

// 대상 폴더에 파일이 있고 덮어 쓸 의도라면
File.Copy("test.log", "test.dat", true);

 

3번째 인자의 불린 값이 true이면 대상 경로에 이미 파일이 있어도 덮어쓰기를 한다.

 

File.Move 메서드는 원본과 대상을 가리키는 2개의 인자만 갖는다. 파일의 위치를 옮기는 목적말고도 Move의 특성으론 폴더가 같을 경우 파일명을 변경(rename)하는 용도로사용된다.

//폴더가 동일하다면 파일명 변경
File.Move("test.log", "test.dat");
//폴더가 다르다면 파일 이동
File.Move("test.log", "c:\\temp\\test.dat");

 

File.Move 메서드에서 주의할 부분은 대상이 되는 경로에 같은 이름의 파일이 이미 존재하면 IOException 예외가 발생된다. File.Copy처럼 덮어쓴느 옵션도 없기에 파일 유무를 먼저 확인 후 삭제하는 작업을 진행하자.

string target = "c:\\temp\\test.dat";

if(File.Exists(target) == true)
{
    File.Delete(target);
}
File.Move("test.log", target);

 

파일의 내용을 한번에 읽고 쓰는 메서드(File.WriteAllText)는 다음과 같이 사용한다.

string text = new string('c', 20); // 문자 c를 20개 반복한 문자열 생성
File.WriteAllText("test.log", text);

string clone = File.ReadAllText("test.log");
Console.WriteLine(clone);


#출력
cccccccccccccccccccc

 

 

File 타입은 정적 클래스다. 반면 FileInfo 타입은 File 타입의 기능을 인스턴스 멤버로 일부 구현하고 있다는 차이점이 있으며 이것을 제외하면 거의 모든 면에서 사용법이 같다.

FileInfo src = new FileInfo("TEST.LOG");
FileInfo tar = new FileInfo("c:\\temp\\test.dat");

if(tar.Exists == true)
{
    tar.Delete();
}
src.MoveTo(tar.FullName);

 

 

6.5.3 System.IO.Directory/System.IO.DirectoryInfo

Directory 타입과 DirectoryInfo 타입의 관계도 File/FileInfo의 관계와 동일하다. Directory 타입은 정적 멤버로 구성된 정적 타입이고 DirectoryInfo는 Directory의 일부 기능을 인스턴스 멤버로 가지고 있다.

 

표6.12 Directory 타입의 정적 메서드

정적 메서드 설명
CreateDirectory 디렉터리를 생성. 이미 디렉터리가 존재하면 아무런 작업도 하지 않는다.
Delete  디렉터리를 삭제한다. 존재하지 않는 디렉터리를 삭제하는 경우 DirectoryNotFoundException 예외 발생
Exists 디렉터리가 존재하는지 여부를 true/false로 반환.
GetDirectories 지정된 경로의 하위 디렉터리 목록을 문자열 배열로반환. 
GetFiles 지정된 경로에 있는 파일을 문자열 배열로 반환.
GetLogicalDrives 시스템에 설치된 디스크의 드라이브 문자 목록을 string 배열로 반환.
Move 디렉터리를 이동한다.

 

Directory 타입을 이용하면 '윈도우 탐색기'와 유사한 프로그램을 만들 수 있다.

string targetPath = @"C:\Windows\Microsoft.NET\Framework";
foreach(string txt in Directory.GetFiles(targetPath, "*.exe", SearchOption.AllDirectories))
{
    Console.WriteLine(txt);
}

# 출력
...
C:\Windows\Microsoft.NET\Framework\v4.0.30319\dfsvc.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\EdmGen.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\ilasm.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\InstallUtil.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\jsc.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\Microsoft.Workflow.Compiler.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\mscorsvw.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\ngen.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\ngentask.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\RegAsm.exe
C:\Windows\Microsoft.NET\Framework\v4.0.30319\RegSvcs.exe
C:\Windows\Microsoft.NET\Framework\v3.0\Windows Communication Foundation\WsatConfig.exe
C:\Windows\Microsoft.NET\Framework\v3.0\WPF\XamlViewer\XamlViewer_v0300.exe

 

 

" *.exe " 에서 사용된  asterisk 문자를 와일드카드 문자(wildcard character)라고 한다. 또 다른 와일드카드 문자인 '?' 는 어떤 하나의 문자를 나타낸다.

 

표 6.13 와일드카드 예

문자열 의미 사례
net*.* 확장자는 상관없고, 파일명이 'net'으로 시작하는 모든 파일 netframework.dll
net_.dat
nettest.exe
net?.* 확장자는 상관없고 'net'으로 시작하는 총 4글자로 된 파일명을 가진 모든 파일 net1.dll
net_.exe
netp.dat
???.dll 확장자가 DLL이고, 파일명이 3글자인 모든 파일 tes.dll
fra.dll
kor.dll
*. 확장자가 없는 모든 파일 netfx
*script.* 확장자는 상관없고, 파일명이 script로 끝나는 모든 파일 Microsoft.JScript.dll
VBScript.tlb

 

 

6.5.4 System.IO.Path

Path 타입은 파일 경로와 관련해서 유용한 정적 메서드를 제공한다.

표 6.14 Path 정적메서드

정적 메서드 설명
ChangeExtension 첫 번째 인자로 주어진 경로에서 확장자 부분을 두 번째 인자로 전달된 문자열로 바꿔준다.
 
string filePath = ".\example.txt";         
// 확장자 변경
string newFilePath = Path.ChangeExtension(filePath, ".pdf");
Combine 전달된 문자열 인자를 모두 합쳐서 하나의 경로로 만든다.

string folderPath = @"C:\Users\Example\Documents";
string fileName = "example.txt";
// 경로 결합
string fullPath = Path.Combine(folderPath, fileName);
GetDirectoryName 전달된 문자열에서 파일이름이 포함된 경우 그 파일의 부모 디렉터리 이름을 반환한다. 반면 디렉터리 이름이 포함된 경우 그 부모 디렉터리 이름을 반환한다.
string tar = @"C:\AA\DD\\A.exe";
var t = Path.GetDirectoryName(tar);
Console.WriteLine(t);
출력: C:\AA\DD
만일 tar = @"C:\AA\DD\이면
출력: C:\AA
GetExtension 전달된 문자열의 확장자를 반환
GetFileName 전달된 문자열의 파일명 반환
GetFileNameWithoutExtension 전달된 문자열의 파일명을 확장자를 제외 시켜 반환
GetFullPath 전달된 문자열의 파일명을 제외한 경로 반환
GetInvalidFileNameChars 파일 이름으로 부적절한 문자의 배열 반환
GetInvalidPathChars 경로 이름으로 부적절한 문자의 배열을 반환
GetPathRoot 전달된 문자열의 루트 드라이브 문자열 반환
GetRandomFileName 임의의 파일명 반환
GetTempFileName 윈도우의 임시폴더 경로에 임의의 파일을 생성하고 그 경로를 반환
GetTempPath 윈도우의 임시 폴더 경로를 반환.

 

GetInvalidFileNameChars를 통해 파일 생성 시 부적절한 파일명일 경우를 걸러주는 역할

using System;
using System.IO;

class Program
{
    static void Main()
    {
        // 파일 이름에 사용할 수 없는 문자들을 반환
        char[] invalidChars = Path.GetInvalidFileNameChars();

        Console.WriteLine("파일 이름에 사용할 수 없는 문자들:");
        foreach (char c in invalidChars)
        {
            Console.Write(c + ", ");
        }
        Console.WriteLine();

        // 예시: 유효한 파일 이름인지 체크하기
        string fileName = "example|test.txt";

        if (fileName.IndexOfAny(invalidChars) >= 0)
        {
            Console.WriteLine($"'{fileName}'은(는) 유효하지 않은 파일 이름입니다.");
        }
        else
        {
            Console.WriteLine($"'{fileName}'은(는) 유효한 파일 이름입니다.");
        }
    }
}

 

*특이사항(p416)

string newDirName = "my<new";  // 폴더명에 '<' 문자는 허용되지 않는다.
int include = newDirName.IndexOfAny(Path.GetInvalidPathChars());
if (include != -1)
{
    Console.WriteLine("폴더 명에 적절하지 않은 문자가 있음");
}


#출력이 나오지 않음. 
Path.GetInvalidFileNameChars()를 사용하여 폴더 이름에 사용될 수 없는 문자를 확인하기에
해당 메서드를 사용하자.

파일 경로를 다룰 때는 은근 신경 써야할 부분이 많다.

그 중하나는 디렉터리 명이 'C:\temp' 인 경우, test.exe 파일 명과 연결 하하기 위해 중간에 디렉터리 구분 문자 '\'를 추가해 "C:\temp" + "\test.exe"라고 작성해야한다.

그러나, 디렉터리 이름이 "C:\temp\"로 입력될 수 있다는점도 간과할 수 없다. 이런 복잡한 경로 연결 문제를 Path.Combine 메서드가 해결해준다.

string filePath = Path.Combine(@"C:\temp", "test", "myfile.dat");
Console.WriteLine(filePath);
# 출력
C:\temp\test\myfile.dat

  

임시 폴더 생성

// 크기가 0인 임시 파일을 생성하고 그 경로를 반환
string createdTempFilePath = Path.GetTempFileName();
Console.WriteLine(createdTempFilePath);

// 임시 파일을 생성하지 않고 중복될 확률이 낮은 임시 파일 경로를 구한다.
string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Console.WriteLine(tempFilePath);

//출력
C: \Users\asd57\AppData\Local\Temp\tmpdbg3eg.tmp
C:\Users\asd57\AppData\Local\Temp\1zvfnqnd.ghv

 

 

6.6 스레딩

스레드는 명령어를 실행하기 위한 스케줄링 단위이며, 프로세스 내부에서 생성할 수 있다. 이는 운영체제에서 멀티 스레딩을 지원한다면 하나의 프로세스가 여러 개의 스레드 자원을 가질 수 있음을 의미한다.

 

스레드는 CPU의 명령어 실행과 관련된 정보를 보관하고 있는데, 이를 스레드 문맥(thread context)이라 한다. OS의 스케줄러는 실행돼야 할 적절한 스레드를 골라서 cpu로 하여금 실행되게 만드는 데, 이때 두 가지 동작을 수행한다. Cpu는 현재 실행 중인 스레드를 다음에 다시 이어서 실행할 수 있게 CPU의 환경 정보를 스레드 문맥에 보관한다. 그리고OS로부터 할당받은 스레드의 문맥  정보를 다시 CPU 내부로 로드해서 마치 해당 스레드가 실행되고 있었던 상태인 것처럼 복원한 다음, 일정 시간동안 실행을 계속한다. 이러한 행위는 스레드 문맥교환(context switching)을 일컫는다.  

 

Cpu가 기존 실행하던 스레드가 test.exe에 속하고 새롭게 할당된 스레드도 test.exe에 속하면 운영체제는 프로세스가 바뀐 것은 아니므로 프로세스의 문맥 정보를 바꾸지는 않지만 다른 프로세스에 속한 스레드로 실행이 변경되면 프로세스의 문맥정보까지 바뀌게 되고 이 작업은 더 많은 CPU 자원을 소모한다.

 

C#은 다중 스레드 응용프로그램을 만들 수 있으며 멀티 CPU/코어의 기능을 충분히 활용할 수 있게 병렬 라이브러리를 지원한다.

 

6.6.1 System.Threading.Thread

Thread thread = Thread.CurrentThread;
Console.WriteLine(thread.ThreadState);

#출력
Running

주스레드 상태.


Thread의 정적 메서드로는 Sleep 메서드가 있으며 이를 이용하면 현재 Running 상태인 스레드의 실행을 지정된 밀리초 만큼 ThreadState.WaitSleepJoin 상태로 변경할 수 있다.

중단된 스레드는 지정된 시간이 지난 후 다시 Running 상태로 돌아온다.

 

 

새로운 스레드를 하나 생성해보는, 스레드는 실행될 명령어가 필요하므로 명령어의 묶음인 메서드를 Thread 생성자에 전달해보자. 일단 스레드 개체가 생성되면 Start 메서드를 호출하는 것으로 스레드를 시작할 수 있다.

Thread t = new Thread(threadFunc);
t.Start();  // threadFunc 호출
void threadFunc(object? obj)
{
    Console.WriteLine("threadFunc run!");
}

 최근 다중 코어 cpu는 실제로 여러 스레드의 코드를 동시에 실행 가능하다.

 

스레드의 종료 = 프로그램의 종료.

기본적으로 프로그램은 생성된 모든 스레드가 실행을 종료해야만 프로그램도 종료 할 수 있다.

 

예제 6.18 2개의 스레드 실행이 완료된 후 프로그램 종료

Thread t = new Thread(threadFunc);
t.Start();

//주 스레드가 실행할 명령어가 없기에 주스레드는 제거됨.
void threadFunc(object? obj)
{
    Console.WriteLine("60초 후에 프로그램 종료");
    Thread.Sleep(1000 * 60); // 현재 주 스레드는 종료됐어도 t 스레드는 존속한다.
    Console.WriteLine("스레드 종료!");
}

#출력
#출력
60초 후에 프로그램 종료
스레드 종료!

프로그램의 실행 종료에 영향을 미치는 스레드를 가리켜 전경 스레드(foreground thread)라 한다.

배경 스레드(background thread)는 실행 종료에 영향을 미치지 않는다.

 

Thread 타입의 IsBackground 속성을 true로 바꿔 전경 스레드 동작을 배경 스레드로 바꿀 수 있다.

Thread t = new Thread(threadFunc);
t.IsBackground = true;
t.Start();

//주 스레드가 실행할 명령어가 없기에 주스레드는 제거됨.
void threadFunc(object? obj)
{
    Console.WriteLine("60초 후에 프로그램 종료");
    Thread.Sleep(1000 * 60); // 현재 주 스레드는 종료됐어도 t 스레드는 존속한다.
    Console.WriteLine("스레드 종료!");
}

#출력
없음

그러면threadFunc 종료 관계없이 주스레드(Main 메서드를 실행하는 스레드)가 종료되면서 프로그램(프로세스)이 종료된다. ( 프로그램 종료 전 아주 낮은 확률로 threadFunc 메서드의 첫번째 Console.WriteLine 메서드가 수행될 수도 있다.)

 

주 스레드에서 스레드 객체의 Start 메서드를 실행했음에도 threadFunc에 있는 단 한줄의 코드 조차 실행이 안되는 이유는 스레드가 cpu에 의해 선택되어 실행될 수 있는 단계까지 시간이 걸리기 때문이다. 즉, threadFunc을 실행해야 할 스레드가 OS의 스케줄러에 의해 선택되기도 전에 Main 메서드를 실행하는 스레드가 종료됐으므로 threadFunc의 코드를실행하지 못하고 끝나게 된다.

 

떄로는 다른 스레드의 실행이 종료되기 까지 기다려야 할 수 도 있는데 이를 위해 Thread 타입의 Join 메서드를 사용 할 수 있다.

 

새로운 스레드(t)가 배경 스레드임에도 주 스레드가 Join 메서드를 호출해 t스레드의 실행이종료될 때까지 기다리는 코드를 보자.

 

예제 6.19 Join 메서드 사용 예   

 

Thread t = new Thread(threadFunc);
t.IsBackground = true;
t.Start();
t.Join(); // t스레드가 종료할 때까지 현재 스레드를 무한 대기
Console.WriteLine("주 스레드 종료!");

//주 스레드가 실행할 명령어가 없기에 주스레드는 제거됨.
void threadFunc(object? obj)
{
    Console.WriteLine("60초 후에 프로그램 종료");
    Thread.Sleep(1000 * 60); // 현재 주 스레드는 종료됐어도 t 스레드는 존속한다.
    Console.WriteLine("스레드 종료!");
}

#출력
60초 후에 프로그램 종료
스레드 종료!
주 스레드 종료!

 

백그라운드 스레드를 사용함에도 백그라운드 스레드가 끝나기를 기다리는 Join메서드를 사용함으로써 주스레드는 바로 종료하지 않고 백그라운드 스레드(threadFunc)가 종료 된 후 주 스레드(t스레드) 종료가 된다.

 

스레드를 시작하는 측에서도 인자 전달이가능하다. 이를 위해 object 타입의 인자를 하나 전달 받는 스레드 메서드를 준비하고 Thread.Start 메서드에 직접 값을 넣어보자.

// 인자가 있는 메서드의 경우 Thread 생성자는
// ParameterizedThreadStart 델리게이트 타입을 허용한다.
Thread t = new Thread(threadFunc);

//따라서 C# 컴파일러는 위의 코드를 다음과 같이 번역해 컴파일 한다.
// new Thread(new ParameterizedThreadStart(threadFunc));
t.Start(10);
void threadFunc(object? initialValue)
{
    int intValue = (int)initialValue;
    Console.WriteLine(intValue);
}


#출력
10

 

여러개의 인자값을 사용하는 경우

Thread t = new Thread(ThreadFunc);
ThreadParam param = new();
param.Val1 = 10;
param.Val2 = 20;

t.Start(param);
void ThreadFunc(object? initialValue)
{
    ThreadParam value = (ThreadParam)initialValue;
    Console.WriteLine($"{value.Val1}, {value.Val2}");
}

class ThreadParam
{
    public int Val1;
    public int Val2;
}


#출력
10, 20

 

스레드 사용 이점을 경험할 수 있는 예제.

예제 6.20 스레드를 사용하지 않는 계산 프로그램(소수 판별기)

Console.WriteLine("입력한 숫자까지의 소수 개수 출력 (종료: 'x' + Enter)");

while (true)
{
    Console.WriteLine("숫자를 입력하세요.");
    string userNumber = Console.ReadLine();

    if(userNumber.Equals("x", StringComparison.OrdinalIgnoreCase) == true)
    {
        Console.WriteLine("프로그램 종료!");
        break;
    }

    CountPrimeNumbers(userNumber);
}

void CountPrimeNumbers(object initialValue)
{
    string value = (string)initialValue;

    int primeCandidate = int.Parse(value);

    int totalPrimes = 0;

    for(int idx = 2; idx <= primeCandidate; ++idx)
    {
        if(IsPrime(idx) == true)
        {
            totalPrimes++;
        }
    }
    Console.WriteLine($"숫자 {value}까지의 소수 개수: {totalPrimes}");
}

bool IsPrime(int candidate)
{
    if ((candidate & 1) == 0)
        return candidate == 2;
    for(int idx = 3; idx * idx<= candidate; idx += 2)
    {
        if (candidate % idx == 0) return false;
    }
    return candidate != 1;
}


#출력
입력한 숫자까지의 소수 개수 출력 (종료: 'x' + Enter)
숫자를 입력하세요.
1000
숫자 1000까지의 소수 개수: 168
숫자를 입력하세요.
5
숫자 5까지의 소수 개수: 3
숫자를 입력하세요.
x
프로그램 종료!

 

스레드를 사용하지 않아씩에 주 스레드가 계산 작업에 매달리는 동안 사용자는 다른 어떤 키도 입력할 수 없다.

입력한 숫자까지의 소수 개수 출력 (종료: 'x' + Enter)
숫자를 입력하세요.
1000000
숫자 1000000까지의 소수 개수: 78498
숫자를 입력하세요.
10000000
숫자 10000000까지의 소수 개수: 664579
숫자를 입력하세요.

10000000 입력 시 값 계산될때까지 사용 대기상태에 빠진다. 

이 단점을 스레드를 사용함으로써 해결 해보자.

 

예제 6.21 스레드를 사용한 계산 프로그램

Console.WriteLine("입력한 숫자까지의 소수 개수 출력 (종료: 'x' + Enter)");

while (true)
{
    Console.WriteLine("숫자를 입력하세요.");
    string userNumber = Console.ReadLine();

    if(userNumber.Equals("x", StringComparison.OrdinalIgnoreCase) == true)
    {
        Console.WriteLine("프로그램 종료!");
        break;
    }

    Thread t = new Thread(CountPrimeNumbers);
    t.IsBackground = true;
    t.Start(userNumber);
}

void CountPrimeNumbers(object initialValue)
{
    string value = (string)initialValue;

    int primeCandidate = int.Parse(value);

    int totalPrimes = 0;

    for(int idx = 2; idx <= primeCandidate; ++idx)
    {
        if(IsPrime(idx) == true)
        {
            totalPrimes++;
        }
    }
    Console.WriteLine($"숫자 {value}까지의 소수 개수: {totalPrimes}");
}

bool IsPrime(int candidate)
{
    if ((candidate & 1) == 0)
        return candidate == 2;
    for(int idx = 3; idx * idx<= candidate; idx += 2)
    {
        if (candidate % idx == 0) return false;
    }
    return candidate != 1;
}


#출력
입력한 숫자까지의 소수 개수 출력 (종료: 'x' + Enter)
숫자를 입력하세요.
10000000
숫자를 입력하세요.
숫자 10000000까지의 소수 개수: 664579
50

백그라운드에서 계산을 기다리지 않고  메인(주)스레드에서 숫자를 입력하라는 출력창이 나옴과 동시에 명령에 INPUT 값을 입력 할 수 있다.

 

 

6.6.2 System.Threading.Monitor

스레드는 메모리가 허용하는 한 원하는 만 큼 생성 가능하다.

하나의 스레드에 할당된 스택 요량은 1MB이다. 32비트 윈도우에서 32비트 프로세스는 2GB의 사용자 메모리가 허용된다. 그래서 스레드  2000개(1MB * 2048 = 2GB)를 넘을 수는 없다.

64비트에선 사용자메모리에 테라 바이트 단위로 할당이 가능하기에 컴퓨터 성능만 받쳐주면 스레드 수에 제한이 풀렸다고 봐도 무방하다.

for (int idx = 0; idx < 10; ++idx)
{
    Thread t = new Thread(threadFunc);
    t.Start(idx);
}

void threadFunc(object? value)
{
    Console.WriteLine(value + "번쨰 스레드");
}

#출력
6번쨰 스레드
1번쨰 스레드
7번쨰 스레드
2번쨰 스레드
9번쨰 스레드
4번쨰 스레드
8번쨰 스레드
5번쨰 스레드
3번쨰 스레드
0번쨰 스레드

 

다중 스레드 사용 시 스레드의 실행순서를 장담할 수 없다.

 

예제 6.22 다중 스레드에서 단일 변수 사용

class Program
{
    int number = 0;
    static void Main(string[] args)
    {
        Program pg = new();

        Thread t1 = new Thread(threadFunc);
        Thread t2 = new Thread(threadFunc);

        t1.Start(pg);
        t2.Start(pg); // 2개의 스레드를 시작하고

        t1.Join();
        t2.Join(); // 2개의 스레드 실행이 끝날 때까지 대기.

        Console.WriteLine(pg.number); //  스레드 실행 완료 후 number 필드 값을 출력

        void threadFunc(object? inst)
        {
            Program pg = inst as Program;

            for(int idx = 0; idx < 10; ++idx)
            {
                pg.number += 1; // Program 객체의 number 필드 값을 증가.
            }
        }
    }
}

#출력
20

 

2개의 스레드가 10번의 루프를 돌면서 number 값을 1씩 증가시키고 있다. 이에 대부분 20이 출력된다. 

 

1-> 100000  으로 늘리면 출력 결과가 시시때때로 달라진다.

 

예제 6.22 다중 스레드에서 단일 변수 사용

class Program
{
    int number = 0;
    static void Main(string[] args)
    {
        Program pg = new();

        Thread t1 = new Thread(threadFunc);
        Thread t2 = new Thread(threadFunc);

        t1.Start(pg);
        t2.Start(pg); // 2개의 스레드를 시작하고

        t1.Join();
        t2.Join(); // 2개의 스레드 실행이 끝날 때까지 대기.

        Console.WriteLine(pg.number); //  스레드 실행 완료 후 number 필드 값을 출력

        void threadFunc(object? inst)
        {
            Program pg = inst as Program;

            for(int idx = 0; idx < 100000; ++idx)
            {
                pg.number += 1; // Program 객체의 number 필드 값을 증가.
            }
        }
    }
   
}

 

여러개 의 스레드들이 끊임 없이 스레드 문맥교환을 통해 number의 값을 증가시키고 있다.

 

추가로 pg.number += 1 명령어를 CPU 입장에서는 다음과 같이 진행된다.

1. 메모리의 힙 영역에서 number 변수에 해당하는 값을 가져온다.

2. 가져온 값에 1을 더한다.

3. 1이 더해진 새로운 값을 메모리의 힙 영역에 저장한다.

C#코드로 작성한 한 줄의 코드는 CPU입장에서 상기 3개의 작업으로 나뉜다. 

 

스레드 실행방식과 결합하여 예제 6.22 문제를 생각하면 다음과 같이 해석할 수 있다.

 

1. number 변수는 0으로 초기화 돼있다.

2. CPU 1번에서 t1 스레드를 실행한다. t1은 메모리부터 number 변수의 값을 가져 온다.

3. t1 스레드는 number 변수의 값에 1을 더한다(아직 저장하지 못함)

4. CPU 1번에서 t1 스레드의 실행이 멈추고 t2스레드를 선택해 실행한다.

5. t2 스레드는 메모리에서 number 변수의 값을 가져온다. 현재 number 변수가 가리키는 주소의 메모리에 있는 값은 0이다.

6. t2 스레드는 number 변수의 값에 1을 더한다.

7. t2 스레드는 1로 증가된 number 변수의 값을 메모리에 저장한다.

8. CPU 1번에서 t2 스레드의 실행이 멈추고 다시 t1 스레드를 선택해 실행한다.

9. t1스레드는마지막으로 3번 작업 까지 수행했다. 따라서 1만큼 증가시켰던 number 변수의 값 1을 메모리에 저장한다.

 

t2 스레드가 7번 단계에서 메모리에 1을 기록하고, t1스레드가 9번단계에서 다시 메모리에 1을 기록한다. t1, t2 스레드에서 하나의 변수에 대해 2번의 더하기를 했지만 CPU가 스레드를 선택해서 실행하는 특성으로 인해 1번의 더하기 결과가 돼 버린 것이다. 이것이 예제 6.22의 출력 값이 언제나 예상했던 결과 값 보다 작은 수로 나오는 원인이다.

 

이러한 상황을 '공유 리소스(shared resource)에 대한 스레드의 동기화(synchronization) 처리가 되지 않았다.' 라고 표현한다.

 예제 6.22에서 공유 리소스는 number 필드이다. 즉 2개의 스레드가 number 필드에 동시에 접근하기 때문에 오동작이 발생한 것이다.

이를해결하고자 공유 리소스에 대한 적절한 '동기화 처리'를 해야한다.

 

여러 방법 중 6.22에 해당되는 경우는 number 필드를 한 순간에 오직 한개의 스레드만 접근할 수 있게 만들어주는, BCL에서 제공하는 클래스인 Monitor를 사용하면 된다. 예제 6.22에서 공유 자원에 접근하는 코드 앞 뒤로 Monitor 를 사용하면 문제가 해결 된다. 

 

class Program
{
    int number = 0;
    static void Main(string[] args)
    {
        Program pg = new();

        Thread t1 = new Thread(threadFunc);
        Thread t2 = new Thread(threadFunc);

        t1.Start(pg);
        t2.Start(pg); // 2개의 스레드를 시작하고

        t1.Join();
        t2.Join(); // 2개의 스레드 실행이 끝날 때까지 대기.

        Console.WriteLine(pg.number); //  스레드 실행 완료 후 number 필드 값을 출력

        void threadFunc(object? inst)
        {
            Program pg = inst as Program;

            for (int idx = 0; idx < 100000; ++idx)
            {
                Monitor.Enter(pg);
                try
                {
                    pg.number += 1; // Program 객체의 number 필드 값을 증가.
                }
                finally
                { 
                    Monitor.Exit(pg); 
                }
            }
        }
    }
}

#출력

언제나 200000 이 나온다.

 

Monitor.Enter와 Monitor.Exit 코드 사이에 위치한 모든 코드는 한 순간에 스레드 하나만 진입해서 실행할 수 있다.(cpp의 크리티컬 섹션 느낌)

 

Enter와 Exit 메서드의 인자로 전달하는 값은 반드시 참조형 타입의 인스턴스여야 한다. Monitor 코드를 적용 후 스레드의 동작 분석을 하면 다음과 같다.

 

1. number 변수는 0으로 초기화 돼있다.

2. CPU 1번에서 t1 스레드를 실행한다.

3. Monitor.Enter 코드를 실행한다. 이로 인한 결과로 t1은 pg 객체로부터 잠금을 획득한다.(아직 pg에 대해 이전에 잠금을 획득한 스레드가 없었으므로 성공적으로 pg의 잠금을 획득한다.)

4. t1은 메모리에서 number 변수의 값을 가져온다.

5. t1 스레드는 number 변수의 값에 1을 더한다(아직 저장하지 못했다.)

6. CPU 1번에서 t1 스레드의 실행이 멈추고 t2스레드를선택해 실행한다.

7. t2 스레드는 Monitor.Enter 코드를 실행한다. 그런데 pg 객체의 잠금이 이미 t1에 의해 점유되고 있으므로 t2스레드는 잠금을 얻지 못한다. 잠금을 얻지 못하였기에, t1 스레드가 잠금을 풀 때까지 스레드는 대기 상태로 들어간다.

8. CPU 1번에서 t2 스레드를 더는 실행할 수 없으므로 t1스레드를 선택해 실행한다.

9. t1 스레드는 마지막으로 5번 작업까지 수행했다. 1만큼 증가시켰던 number 변수의 값을 메모리에 저장한다.

10. t1 스레드는 Monitor.Exit 코드를 실행하면서 지정된 pg 객체의 잠금을 해제 한다.

11. CPU 1번에서 t1 스레드의 실행이 멈추고 t2스레드를 선택해 실행한다.

12. t2스레드는 t1스레드가 pg 객체의 잠금을 해제했으므로 이제는 Monitor.Enter에서 pg 객체의 잠금을 얻어서 진입할 수 있다.

13. 메모리로부터 number 변수의 값을 가져오고 증가시킨 후 다시 메모리에 저장한다.

14. Monitor.Exit 코드를 실행한다. 이렇게 pg 객체의 잠금을 해제 함으로써 다른 스레드가 pg 객체의 잠금을 얻을 수있게 한다.

 

결과적으로 공유 객체에 대한 접근 코드가 스레드 하나에만 허용되기에 정상적으로 더하기 작업이 잘 수 행됐다.

 

부가적으로 C#언어에선 try/finally + Monitor.Enter/Exit 코드와 동일한 역할을 하는 lock 예약어를 제공한다.  

 

 

class Program
{
    int number = 0;
    static void Main(string[] args)
    {
        Program pg = new();

        Thread t1 = new Thread(threadFunc);
        Thread t2 = new Thread(threadFunc);

        t1.Start(pg);
        t2.Start(pg); // 2개의 스레드를 시작하고

        t1.Join();
        t2.Join(); // 2개의 스레드 실행이 끝날 때까지 대기.

        Console.WriteLine(pg.number); //  스레드 실행 완료 후 number 필드 값을 출력

        void threadFunc(object? inst)
        {
            Program pg = inst as Program;

            for (int idx = 0; idx < 100000; ++idx)
            {
                lock(pg)
                {
                    pg.number += 1; // Program 객체의 number 필드 값을 증가.
                }
            }
        }
    }

}

 

대개 코드가 간결한 lock 예약어를 많이 선호한다.

 

예쩨 6.22 코드를 캡슐화 원칙에 맞게 재구성한 코드이다. number필드를 객체 외부에서 직접 접근하지 못하게 했다.

class MyData
{
    int number = 0;
    public int Number { get { return number; } }
    public void Increment()
    {
        number++;
    }

    class Program
    {
        static void Main(string[] args)
        {
            MyData data = new();

            Thread t1 = new Thread(threadFunc);
            Thread t2 = new Thread(threadFunc);

            t1.Start(data);
            t2.Start(data); // 2개의 스레드를 시작하고

            t1.Join();
            t2.Join(); // 2개의 스레드 실행이 끝날 때까지 대기.

            Console.WriteLine(data.number); //  스레드 실행 완료 후 number 필드 값을 출력
        }
        static void threadFunc(object? inst)
        {
            MyData data = inst as MyData;

            for (int idx = 0; idx < 100000; ++idx)
            {
                data.Increment();
            }
        }
    }

}

 

lock 예약어가 없기에 '스레드에 안전하지 않은(not thread-safe) 메서드'라고 표현한다. 

 

예제 6.24 스레드에 안전한 메서드

class MyData
{
    int number = 0;
    public object _numberLock = new object();
    public int Number { get { return number; } }
    public void Increment()
    {
        lock(_numberLock)
        {
            number++;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            MyData data = new();

            Thread t1 = new Thread(threadFunc);
            Thread t2 = new Thread(threadFunc);

            t1.Start(data);
            t2.Start(data); // 2개의 스레드를 시작하고

            t1.Join();
            t2.Join(); // 2개의 스레드 실행이 끝날 때까지 대기.

            Console.WriteLine(data.number); //  스레드 실행 완료 후 number 필드 값을 출력
        }
        static void threadFunc(object? inst)
        {
            MyData data = inst as MyData;

            for (int idx = 0; idx < 100000; ++idx)
            {
                data.Increment();
            }
        }
    }

}

 

lock 예약어를 사용해 위와 같이 동기화처리를 진행했다. 다만 동기화 처리 방법은 그 타입의 소스코드를 변경할 수 있냐에 따라 달라진다. 예제 6.24 처럼 lock 예약어를 사용해 직접 코드를 수정할 수 있는 반면, 소스코드를 고치지 못하는 구조이면 외부에서 스레드에 안전하지 않은 메서드를 호출할 때마다 예제 6.25의 경우 처럼 동기화 코드를 수행해야한다.

 

예제 6.25 스레드에 안전하지 않은 메서드를 외부에서 안전하게 사용하는 방법.

static void threadFunc(object? inst)
{
    MyData data = inst as MyData;

    for (int idx = 0; idx < 100000; ++idx)
    {
        lock (data)
        {
            data.Increment();
        }

    }
}

 

ArrayList 타입의 경우 모든 정적 멤버는 다중 스레드 접근에 안전하나, 인스턴스 멤버는 다중 스레드로 접근했을 때 안전하지 않다. 이는 ArrayList 뿐만 아니라 ms에서 만든 거의모든 BCL에 대해 이와 동일한 정책이 적용된다.

모든 메서드를 처음부터 스레드에 안전한 방식으로 만들지 않은 이유는 성능 문제로 대부분의 경우 단일 스레드에서만 접근하기에 LOCK 보호장치는 성능상 좋지 않다. 

 

BCL의 모든 타입을 사용할 때 인스턴스 멤버에 대해 기본적으로 스레드에 안전하지 않다는점을 염두하고 동기화가 필요할 때 직접 예제 6.25처럼 외부에서 처리해야한다.  

 

6.6.3 System.Threading.Interlocked

Interlocked 타입은 정적 클래스다. 다중 스레드에서 공유자원을 사용하는 몇몇 패턴에 대해선 명시적인 동기화 작업을 필요없게 만드는 정적 메서드를 제공한다.

예를 들어, 32 bit/ 64 bit 숫자형 타입의 더하기 및 증가/감소와 같은 일부 연산에 대해서는 lock(또는 Monitor)을 사용하지 않고도 Interlocked 타입을 이용해 처리 할 수 있다. 

예제 6.24는 Interlocked를 이용해 더 간단히 구현 가능하다.

class MyData
{
    int number = 0;
    public int Number { get { return number; } }
    public void Increment()
    {
        Interlocked.Increment(ref number);
    }
}

 

Interlocked 타입의 정적 메서드로 제공되는 연산의 단위를 '원자적 연산(atomic operation)'이라 한다. 원자적 연산은 하나의 스레드가 그 연산 상태에 들어갔을 때 그 연산은 더는 나뉠 수 없는 단일 연산으로 취급받기 때문에 다른 스레드가 중간에 개입할 수 없음을 의미한다.

 

Interlocked 타입은 대입 연산을 원자적 단위로 수행할 수 있게끔 정적메서드를 제공한다.

long n = 0;
Interlocked.Exchange(ref n, 5);

 

상기 코드는 n = 5를 수행한다. 단지 32비트/64비트 컴퓨터에 상관없이 64비트의 long 타입에 값을 대입하는 작업을 원자적 단위로 수행한다.

 

정리하면, lock 구문의 블록에 있는 모든 연산은 논리적으로 원자적 연산에 속한다. 스레드 입장에서 lock 블록의코드가 실행되는 동안 다른 스레드가 절대 그 연산의 중간에 끼어들 수 없다. 만일 원자적 연산에 속하지 않는다면 중간에 다른 스레드가 끼어들기에 결과값이 다를 수 있다는 점을 유의하자.

몇몇 단순한 유형의 연산에 대해서는 복잡한 lock 구문 대신 Interlocked 타입으로 대체 가능하다.

 

6.6.4 System.Threading.ThreadPool

스레드 동작 방식 유형

1. 상시 실행: 스레드가 일단 생성되면 비교적 오랜 시간 동안 생성돼 있는 유형. 

예를 들어, 특정 디렉터리의 변화를 감시하는 스레드가 필요하면 이는 그 동작이 필요 없어질 때까지 스레드가 유지돼야 한다. 대개의 경우 무한 루프를 가지고 있다.

 

2. 일회성의 임시 실행: 특정 연산만을 수행하고 바로 종료하는 유형.  대표적 예) 예제 6.21, 예제 6.22

 

1번 유형을 위해 스레드를 생성하고 유지하는 것은 당연하다. 그러나, 2번 유형 떄문에 매번 스레드를 생성하는 것은 다소 불편 할 수 있다.

임시적인 목적으로 언제든 원할 때 스레드를 사용하고자 CLR에서 기본적인 스레드 풀(thread pool)을 지원해준다.

풀(pool): 재사용할 수 있는 자원의 집합.

스레드 풀: 스레드를 필요할 때마다 꺼내 쓰고 필용벗어지면 다시 풀에 스레드가 반환되는 기능.

 

 

예제 6.26 ThreadPool을 이용한 예

class MyData
{
    int number = 0;
    public int Number { get { return number; } }
    public void Increment()
    {
        Interlocked.Increment(ref number);
    }
}
class Program
{
    static void Main(string[] args)
    {
       MyData data = new MyData();
        ThreadPool.QueueUserWorkItem(threadFunc, data);
        ThreadPool.QueueUserWorkItem(threadFunc, data);

        Thread.Sleep(1000);
        Console.WriteLine(data.Number);
    }
    static void threadFunc(object? inst)
    {
        MyData data = inst as MyData;

        for (int idx = 0; idx < 100000; ++idx)
        {
            lock (data)
            {
                data.Increment();
            }

        }
    }
}

 

스레드 생성 코드가 생략된 대신 ThreadPool 타입의 QueueUserWorkItem 메서드에 threadFunc, data를 전달하고 있다. 두번을 호출하였기에 스레드 풀에는 2개의 스레드가 자동으로 생성되고 각 스레드에 threadFunc 메서드가 할당되어 실행된다.

 

그러면, 일회성으로 스레드가 필요한 경우 직접 Thread 객체를 만들어 실행할지, 아니면 ThreadPool에 맡겨서 실행할지 결정하는 기준은?

어느 방식으로든 상관없다. 특별히 ThreadPool이 더욱 효율적이라고 판단되는 상황을 가려내자면 내부 동작 방식을 이해하는 것이 도움이 된다.

 

예제 6.26의 경우를 통한 ThreadPool 동작 방식

1. ThreadPool은 프로그램 시작과 함께 0개의 스레드를 가지며 생성된다.

2. 첫번째 QueueUserWorkItem을 호출했을 때 ThreadPool에 자동으로 1개의 스레드를 생성해 threadFunc을 할당해 실행한다.

3. 두번째 QueueUserWorkItem을 호출했을 때 ThreadPool에 일하고 있지 않은 스레드가 있는지 확인한다. 그런 스레드가 있다면 threadFunc을 할당해서 수행시킨다. 없다면 새롭게 스레드를 생성하고 threadFunc을 할당해 실행한다. 예제 6.26의 경우 여유 스레드가 없기 때문에 새로운 스레드가 생성된다.

4. 현재 2개의 스레드가 생성됐고 각 스레드에 할당된 threadFunc 작업이 수행된다.

5. threadFunc 메서드의 실행을 마친 스레드는 곧바로 종료되지 않고 스레드 풀에 일정 시간 동안 보관된다. 보관돼 있는 시간 동안 다시 QueueUserWorkItem이 실행되어 스레드가 필요해지면 곧바로 활성화되어 주어진 메서드를 실행한다.

6. 일정 시간 동안 재사용되지 않는다면 스레드는 풀로부터 제거되어 완전히 종료된다.

 

 

한번 생성된 스레드는 일정시간 동안 재사용된다는 점이 ThreadPool의 주요 특징 중 하나다. 여기서 스레드가 OS의 커널 자원으로 생성된다는 점을 염두에 두자. 이는 스레드 하나를 생성/종료하는 데 소비되는 CPU 사용량이 크다는 것을 의미한다. 따라서 스레드를 자주 생성해서 사용하는 프로그램이 있다면 매번 Thread 객체를 생성하기 보단 ThreadPool로부터 재사용했을 때 더 나은 성능을 보인다.

 

6.6.5 System.Threading.EventWaitHandle

EventWaitHandle은 Monitor 타입처럼 스레드 동기화 수단의 하나이다. 스레드로 하여금 이벤트(event)를 기다리게 만들 수 있고, 다른 스레드에서 원하는 이벤트를 발생시키는 시나리오에 적합하다.

이 때 이벤트 객체는 딱 두가지 상태만 갖는데, Signal과 Non-Signal 로 나뉘고서로 간의 상태 변화는 Set, Reset 메서드로 전환 할 수 있다.

[이벤트 객체의 상태 변화]

Non Signal  --   (Set) ---> Signal. 

Signal  --- (Reset) ---> Non Signal

 

이벤트 객체는 WaitOne 메서드를 제공한다. 어떤 스레드가 WaitOne 메서드를 호출하는 시점에 이벤트 객체가 Signal 상태이면 메서드에서 곧바로 제어가 반환되지만, Non-Signal 상태였다면 이벤트 객체가 Signal 상태로 바뀔 때까지 WaitOne 메서드는 제어를 반환하지 않는다. 즉, 스레드는 더 이상 실행하지 못하고 대기 상태로 빠진다.

예로서는 Thread 객체의 Join 메서드가 하는 동작을 유사하게 구현 가능하다. 다음은 예제 6.19에서 Join 메서드를 제거하고 EventWaitHandle로 대체한 예제다.

// Non-Signal 상태의 이벤트 객체 생성
// 생성자의 첫 번째 인자가 false이면 Non-Signal 상태로 시작, true이면 Signal 상태로 시작


EventWaitHandle ewh = new EventWaitHandle(false, EventResetMode.ManualReset);

Thread t = new Thread(threadFunc);

t.IsBackground = true;
t.Start(ewh);

// Non-Signal 상태에서 WaitOne을 호출했으므로 Signal 상태로 바뀔 때까지 대기
ewh.WaitOne();
Console.WriteLine("주 스레드 종료!");
void threadFunc(object state)
{
    EventWaitHandle ewh = state as EventWaitHandle;

    Console.WriteLine("5초 후에 프로그램 종료");
    Thread.Sleep(1000 * 5);
    Console.WriteLine("스레드 종료");

    // Non-Signal 상태의 이벤트를 Signal 상태로 전환.
    ewh.Set();
}

 

주 스레드는 ewh.WaitOne을 호출하면서 해당 이벤트 객체가 Signal 상태로 바뀌기를 기다린다. 다른 스레드에서 실행되던 threadFunc 메서드가 시간이 지나 ewh.Set 메서드를 호출해 이벤트의 상태를 Signal로 바꾸면 주 스레드는 대기 상태에서 깨어나 실행을 계속하게 된다. 말 그대로 스레드 간에 신호를 전달하는 역할을 담당한다.

 

Join 메서드의 역할을 EventWaitHandle 객체로 우회해서 구현할 수 있기에 이를 응용해 ThreadPool의 단점을 보완할 수 있다.

 

예쩨 6.26을 보면 스레드의 Join 메서드를 호출할 수 없었기 때문에 동작의 완료 여부를 알 수 없어 임시로 Thread.Sleep 메서드를 이용했다. 이를 EventWaitHandle로 대체하여 개선 가능하다.

 

예제 6.27 개선된 ThreadPool의 사용 예.

using System.Collections;

class MyData
{
    int number = 0;
    public int Number { get { return number; } }
    public void Increment()
    {
        Interlocked.Increment(ref number);
    }
}
class Program
{
    static void Main(string[] args)
    {
        MyData data = new MyData();

        Hashtable ht1 = new();
        ht1["data"] = data;
        ht1["evt"] = new EventWaitHandle(false, EventResetMode.ManualReset);
        //데이터와 함께 이벤트 객체를 스레드 풀의 스레드에 전달한다.
        ThreadPool.QueueUserWorkItem(threadFunc, ht1);

        Hashtable ht2 = new();
        ht2["data"] = data;
        ht2["evt"] = new EventWaitHandle(false, EventResetMode.ManualReset);
        //데이터와 함께 이벤트 객체를 스레드 풀의 스레드에 전달한다.
        ThreadPool.QueueUserWorkItem(threadFunc, ht2);

        //2개의 이벤트 객체가 Signal 상태로 바뀔 때까지 대기한다.
        (ht1["evt"] as EventWaitHandle).WaitOne();
        (ht2["evt"] as EventWaitHandle).WaitOne();

        Console.WriteLine(data.Number);

    }
    static void threadFunc(object? inst)
    {
        Hashtable ht = inst as Hashtable;

        MyData data = ht["data"] as MyData;
        
        for(int idx = 0; idx < 100000; ++idx)
        {
            data.Increment();
        }

        //주어진 이벤트 객체를 Signal 상태로 전환한다.
        (ht["evt"] as EventWaitHandle).Set();
    }
}

 

QueueUserWorkItem이 1개의 인자만을 스레드 메서드에 전달하도록 허용하기 때문에 Hashtable을 이용해 인자를 담아 전달하는 식으로 소스코드를 변경했다. 예제 6.26과 완전 동일한 동작을 가진다.

 

이벤트는 수동 리셋(manual reset) 이벤트와 자동 리셋(auto reset) 이벤트로 나뉜다.

두 리셋 방식의 차이점을 설명하면 EventWaitHandle.Set 메서드를 호출해 Signal 상태로 전환된 이벤트가 Non-Signal 상태로 자동으로 전환되는지 여부에 따라 차이가 난다.

자동 리셋이벤트: Set 호출 후 자동으로 Non-Signal로 돌아오는 이벤트

수동 리셋이벤트: Set 호출 후 직접 Reset을 호출해야 Non-Signal로 돌아오는 이벤트

 

예제 6.27에서는 ManualReset을 EventWaitHandle 생성자의 2번째 인자에 전달함으로써 수동 리셋이벤트를 생성하고 있다. 이로 인해 한번 Set이 호출 된 이후로 항상 Signal 상태에 머문다.

예제 6.27의 경우 작업의 종료 여부를 판단하는 목적으로만 이벤트를 사용하기에 수동 리셋 이벤트가 적합하지만, 한번 생성된 이벤트를 재사용해야한다면, 다시 Non-Signal 상태로 바꿔야 하므로 상황에 따라 자동 또는 수동 리셋을 적절히 선택해야 한다.

 

 

자동리셋과 수동 리셋의 차이점:

1. A스레드는 이벤트에 대해 WaitOne 을 호출해서 대기한다.

2. B스레드도 이벤트에 대해 WaitOne 을 호출해서 대기한다.( 현재 2개의 스레드가 같은 이벤트를 이용해 Signal 상태로 바뀌기만을 기다리고 있다.)

3. C 스레드는 이벤트 객체의 Set메서드를 호출함으로써 Signal 상태로 바꾼다.

 

이 상황에서 사용된 이벤트가 자동 리셋 이벤트면 3번의 Set 메서드로 인해 꺠어나는 스레드는 A 또는 B 스레드 중 하나가 된다. 대기하고 있던 단 1개의 스레드만을 꺠운 후(Set) 곧바로 Non-Signal 상태로 바뀌어 버리는 특성이 있다.

대기하고 있던 스레드가 없다면 그에 상관없이 Non-Signal 상태로 바뀐다.

 

수동 리셋 이벤트라면 Set메서드는 이벤트를 Signal 상태로 바꾸고 A스레드와 B스레드가 모두 꺠어날 수 있게 한다. 얼마나 빨리 EventWaitHandle 타입의 Reset 메서드를 호출하는지가 관건이다.

만약 C스레드가 Set메서드를 호출 한 후 곧바로 Reset 메서드를 이어서 호출 했다면 확률적으로 A 또는 B 스레드 중 하나만 깨어날 수도 있고 모두 꺠어날 수도 있다.  수동 리셋 이벤트의 특징은 명시적인 Reset 메서드를 호출하기 까지 이벤트의 signal 상태를 지속시킨다.

 

 

6.6.6 비동기 호출

비동기 호출(asynchronous call)이란 '동기 호출(synchronous call)'과 대비된다.

비동기 호출은 입출력(I/O)장치와 연계하여 설명할 때가 많다. 

파일의 데이터를 읽는 작업에서의 비동기호출을 알아보자.

 

예제 6.28 동기파일의 파일 읽기

// HOSTS 파일을 읽어서 내용 출력
using System.Text;

using (FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read,
    FileShare.ReadWrite))
{
    byte[] buf = new byte[fs.Length];
    fs.Read(buf, 0, buf.Length);

    string txt = Encoding.UTF8.GetString(buf);
    Console.WriteLine(txt);
}


#출력
# Copyright (c) 1993-2009 Microsoft Corp.
#
# This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
#
# This file contains the mappings of IP addresses to host names. Each
# entry should be kept on an individual line. The IP address should
# be placed in the first column followed by the corresponding host name.
# The IP address and the host name should be separated by at least one
# space.
#
# Additionally, comments (such as these) may be inserted on individual
# lines or following the machine name denoted by a '#' symbol.
#
# For example:
#
#      102.54.94.97     rhino.acme.com          # source server
#       38.25.63.10     x.acme.com              # x client host

# localhost name resolution is handled within DNS itself.
#       127.0.0.1       localhost
#       ::1             localhost





127.0.0.1 scskls.softcamp.co.kr

 

여기서 FileStream.Read 메서드는 동기 호출에 속한다. Read메서드는 디스크의 파일로부터 데이터를 모두 읽기 전까진 제어를 반환하지 않는다. 이때문에 다른 말로 동기 호출을 블로킹 호출(blocking call)이라고도 한다.

느린 디스크 i/o가 끝날때까지 스레드는 아무 일도 못하고 스레드 대기상태에 놓인다.

 

이러한 동기 호출의 단점을 해결하고자 비동기 호출이 제공된다.

FileStream은 비동기 호출을 위해 Read/Write 메서드에 대해 각각 BeginRead / EndRead, BeginWrite / EndWrite 메서드를 쌍으로 제공한다. 

 

예제 6.29 비동기 방식의 파일 읽기

using System.Text;

class FileState
{
    public byte[] Buffer;
    public FileStream File;
}

class Program
{
    static void Main(string[] args)
    {
        FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true);

        FileState state = new();
        state.Buffer = new byte[fs.Length];
        state.File = fs;

        fs.BeginRead(state.Buffer, 0, state.Buffer.Length, readCompleted, state);
        
        // BeginRead 비동기 메서드 호출은 스레드로 곧바로 제어를 반환하기 때문에
        // 이곳에서 자유롭게 다른 연산을 동시에 진행 할 수 있다.
        Console.ReadLine();
        fs.Close();
    }

    // 읽기 작업이 완료되면 메서드가 호출된다.
    private static void readCompleted(IAsyncResult ar)
    {
        FileState state = ar.AsyncState as FileState;
        state.File.EndRead(ar);

        string txt = Encoding.UTF8.GetString(state.Buffer);
        Console.WriteLine(txt);
    }
}


#출력
# Copyright (c) 1993-2009 Microsoft Corp.
#
# This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
#
# This file contains the mappings of IP addresses to host names. Each
# entry should be kept on an individual line. The IP address should
# be placed in the first column followed by the corresponding host name.
# The IP address and the host name should be separated by at least one
# space.
#
# Additionally, comments (such as these) may be inserted on individual
# lines or following the machine name denoted by a '#' symbol.
#
# For example:
#
#      102.54.94.97     rhino.acme.com          # source server
#       38.25.63.10     x.acme.com              # x client host

# localhost name resolution is handled within DNS itself.
#       127.0.0.1       localhost
#       ::1             localhost





127.0.0.1 scskls.softcamp.co.kr

 

 

BeginRead 메서드는 디스크로부터 파일 데이터를 읽어낼 때까지 기다리지 않고 곧바로 스레드에 제어를 반환한다.

따라서 스레드는 이후의 코드를 끊김없이 실행 할 수 있다. 원하던 대로스레드가 쉬지 않고 다른 일을 할수 있게 된다.

그리고 읽기 작업이 완료되면 CLR은 스레드풀로부터 유휴 스레드를 하나 얻어와 그 스레드에 readCompleted 메서드의 실행을 맡긴다.

여기서 중요한 점은 BeginRead를 호출한 스레드를 전혀 방해하지 않는다는 점이다.

 

비동기 호출은 io 연산이 끝날때까지 차단되지 않으므로 논블로킹 호출(non-blocking call)이라고 한다.

 

비동기 호출과 스레드 방식(스레드 직접 사용 , 스레드 풀 사용) 의 차이.

using System.Text;

ThreadPool.QueueUserWorkItem(readCompleted);

//QueueUserWorkItem 메서드 호출은 곧바로 제어를 반환하기 때문에
//이곳에서 자유롭게 다른 연산을 동시에 진행 할 수 있다.

Console.ReadLine();
void readCompleted(object? state)
{
    using(FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read
        , FileShare.ReadWrite))
    {
        byte[] buf = new byte[fs.Length];
        fs.Read(buf, 0, buf.Length);

        string txt = Encoding.UTF8.GetString(buf);
        Console.WriteLine(txt);
    }
}


#출력
# Copyright (c) 1993-2009 Microsoft Corp.
#
# This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
#
# This file contains the mappings of IP addresses to host names. Each
# entry should be kept on an individual line. The IP address should
# be placed in the first column followed by the corresponding host name.
# The IP address and the host name should be separated by at least one
# space.
#
# Additionally, comments (such as these) may be inserted on individual
# lines or following the machine name denoted by a '#' symbol.
#
# For example:
#
#      102.54.94.97     rhino.acme.com          # source server
#       38.25.63.10     x.acme.com              # x client host

# localhost name resolution is handled within DNS itself.
#       127.0.0.1       localhost
#       ::1             localhost





127.0.0.1 scskls.softcamp.co.kr

   

읽기 작업을 동기 호출로 ThreadPool의 스레드에 대행했으므로 QueueUserWorkItem 메서드를 호출한 측의 스레드는 다른 작업을 할 수 있다.

 

최초의 스레드가 자유롭게 된 상황은 같지만 스레드 풀로부터 빌려온 스레드의 사용 시간이 비동기호출 부분에서의 스레드 사용 시간 보다 길어졌다. 

일반적인 목적의 응용 프로그램에서 QueueUserWorkItem과 비교 시 비동기 호출로 얻는 이득은 크지 않다. 이 정도의 차이가 의미가 있는 경우는 동시 접속자 수가 많은 게임서버나 웹서버 등이다.

 

 

6.6.7 System.Delegate의 비동기 호출

 

+ Recent posts