제 홈페이지의 모든 글은 anti-nhn license에 따릅니다.



대용량 서비스 - 2. Thread safe vs none Thread safe

Thread 관련된 문제는 항상 강조하는 게 "버그 잡기 힘들다." 입니다. 
Thread 관련 얘기 중 첫번 째로 Thread safe에 대해서 시작해보겠습니다.

1. 원자성

원자성이란 CPU가 처리하는 하나의 단일 연산을 말합니다.
int a=1; 과 같은 코드는 원자성입니다.
int a=1+1; 도 원자성입니다.
그러나
a++;은 원자성이 아닙니다.
a++은 a= a+1 과 같은 의미인데, (a값을 읽는다) + (읽은 값에 1을 더해서 a에 대입한다.) 라는 2개의 원자성 연산의 조합입니다.
원자성을 가진 연산이 2개 이상 있을 때 그것은 원자성이 보장되지 않습니다. 아래의 코드를 실행시켜보세요.

public class AtomicTest {
 private int a=0;
 public int incrementAndGet(){
  return a++;
 }
 public static void main(String[] args) {
  final AtomicTest test = new AtomicTest();
  for (int i = 0; i < 100; i++) {
   new Thread(){
    public void run(){
     for (int j = 0; j < 1000; j++) {
      System.out.println(test.incrementAndGet());
     }
    }
   }.start();
  }
 }
}

100개의 Thread가 1000번씩 증가시켰으면 100,000이 나와야 할 것 같지만 보통 그 보다 좀 덜 나옵니다.
초기값이 0인 a에 대해 a++연산을 Thread A, B가 처리할 경우,
1. A Thread가 a의 값을 읽음. (a=0)
2. B Thread가 a의 값을 읽음. (a=0)
3. A Thread가 a의 값 계산 후 대입. (a=0+1 = 1);
4. B Thread가 a의 값 계산 후 대입. (a=0+1 = 1);
와 같이 흘러가면 결국 a=1이 됩니다. 두번 돌았으니 2가 되어야 하지만 말입니다.

원자성 연산의 조합은 절대 원자성 연산이 아닙니다.



2. Thread safe

Thread safe란 여러 개의 쓰레드에서 동시에 호출해도 문제가 되지 않는 클래스입니다. Thread safe에 대해서 정확히 알려면, 변수의 scope과 자바의 메모리 관리 등에 대해서 좀 알아야 합니다. 자바 기초 책에 나오는 클래스 변수, 지역 변수 등을 명확히 이해해야 하며, 메인 메모리와 워킹 메모리에 대해서 대략적인 가닥은 잡으셔야 합니다.

Thread safe 하게 프로그램을 만들려면 일반적으로 synchronized 구문을 씁니다.
StringBuffer 와 StringBuilder, Vector 와 ArrayList, Hashtable과 HashMap 같은 것들이 synchronized가 걸려있고 안걸려 있고 정도의 차이만을 보이는 대표적인 클래스들입니다.(물론, 그게 전부는 아니지만 핵심입니다.)


StringBuffer, Vector, Hashtable 등이 자바 초기에 등장한 애들입니다. 얘들은 synchronized가 걸려있는 대표적인 애들입니다. synchronized가 걸리면 안 걸린 것에 비해서 당연히 느립니다. 즉, 정말 synchronized 가 걸려있어야 하는지를 보고 그렇다면 그런 애들을 쓰면 됩니다.

먼저 StringBuffer는 일반적으로 쓸 일이 없습니다. 여러 개의 Thread에서 하나의 String을 조합하는 것은 일반적으로 굉장히 이상한 일입니다. 다만 StringBuffer 자체를 인자로 받는 라이브러리를 쓸 때는 어쩔 수 없지만 일반적으로는 쓸 일이 없습니다. 보통은 StringBuilder를 사용하면 됩니다.

Vector와 Hashtable 등을 쓸 수는 있지만, iterator 등을 돌 때는 주의해야 합니다. iterator를 돌면서 추가 삭제를 하는 것은 위험합니다. 다음 프로그램은 에러를 발생시킵니다.(쓰레드 관련 프로그램이 늘 그렇듯... 안 발생할 수도 있습니다.)

public class VectorIteratorTest {
 public static void main(String[] args) {
  final Vector<Integer> a = new Vector<Integer>();
  for (int i = 0; i < 100; i++) {
   a.add(i);
  }
  
  new Thread(){
   public void run(){
    for (int i = 100; i < 1000; i++) {
     a.add(i);
    }
   }
  }.start();
  
  for (Integer integer : a) {
   System.out.println(integer);
  }
 }
}

Exception in thread "main" java.util.ConcurrentModificationException
 at java.util.AbstractList$Itr.checkForComodification(AbstractList.java:372)
 at java.util.AbstractList$Itr.next(AbstractList.java:343)
 at thread.VectorIteratorTest.main(VectorIteratorTest.java:24)

요런일이 생깁니다. Thread safe 하기 때문에 문제가 안 될 것 같지만, iterator 부분이 문제 입니다. 일단 요기 를 읽어보시고...
전부 단일 연산인 것 같지만 iterator를 도는 게 단일 연산인 것은 아니기 때문에 생기는 문제입니다. 저걸 단일 연산으로 맞추고 싶다면 iterator 바깥부분을 synchronized (a) { } 로 감싸주어야 합니다.


3. 잘못 쓰기 쉬운 라이브러리

date format을 처리하기 위해 SimpleDateFormat 클래스를 씁니다. 이놈은 thread safe 하지 않습니다. new 로 매번 생성하거나 생성된 객체를 clone 떠서 사용하기를 권장합니다.

Jaxb Marshaller는 xsd에서 나온 bean 객체를 이용하여 xml을 만들어낼 때 쓰는 놈인데, 이 놈도 thread safe 하지 않습니다. 게다가 이놈은 생성비용도 굉장히 비쌉니다. 생성 과정을 전부 살펴보진 않아서 정확히는 모르겠지만, 동시에 수백개를 생성하게 되면 생성 속도가 몇 초 단위로 나오더군요. 이건 pool을 사용해서 처리했습니다. jakarta common에 있는 common-pool 을 사용했습니다.

Servlet이나 JSP는 Flyweight 로 구현되었습니다. 즉 instance가 1개이기 때문에 동시에 여러 thread가 접근하면 문제가 생길 수 있습니다. 흔히 디비에서 조회한 결과를 멤버 변수에 저장하는 실수를 범하곤 하는데, 인스턴스가 1개이기 때문에 엉뚱한 사람이 그 결과를 보게 될 수 있습니다. 즉 Servlet이나 JSP는 Thread safe하게 제작되어야 합니다.!

다음 예제는 잘못된 서블릿입니다.


public class WrongServlet extends HttpServlet {

private static final long serialVersionUID = 1L;

    private String userName;


protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

fillUserName(request);

response.getWriter().write(userName);

}

private void fillUserName(HttpServletRequest request) {

userName = (String) request.getSession().getAttribute("USER_NAME");

}

}


여러 개의 request가 들어오면 A thread가 set한 userName을 B thread가 읽어 들일 수 있습니다. 



4. Thread 관련된 것은 꼭 테스트를!!


Thread에 관련된 문제는 말씀드렸다시피 잘못 짜도 일반적으로 발생하지 않습니다. 어느 순간 발생하기도 하기 때문에 잡아내기가 정말 힘듭니다. 결과의 정합성에 대해서 "Thread 차원에서" 테스트를 반드시 거치기 바랍니다.


언젠가 Lock에 대해서 다룰 예정인데, 역시 테스트가 굉장히 중요합니다. lock 관련 문제는 잘 나가다가 어느 순간 뻗어버리는 일을 유발할 수 있습니다. 자세한 건 다음에...

by 삼실청년 | 2011/10/27 21:59 | 컴터질~ | 트랙백 | 핑백(1) | 덧글(3)

트랙백 주소 : http://iilii.egloos.com/tb/5564982
☞ 내 이글루에 이 글과 관련된 글 쓰기 (트랙백 보내기) [도움말]
Linked at happy2v : [Link].. at 2012/02/17 21:23

... 삼실 청년 블로그 대용량 서비스 만들기 - 1. 적당한 데이터 클래스 대용량 서비스 만들기 - 2. Thread safe vs none Thread safe 대용량 서비스 - 3. 캐쉬 ... more

Commented by 용식 at 2011/10/29 23:15
사실 병렬처리 프로그래밍을 직접 할 일이.. 거의 없기도 하고.. 있어도 단순히 Job을 나눠서 수행하는 정도이고.. 깊게 하지 않아서.. 많이 생소한 부분이에요..
그래서 테스트케이스를 만들어도.... 참.. 애매하더라고요
"이게 진짜 괜찮은걸까.." 하고요 ㅋㅋ
Commented by 삼실청년 at 2011/10/31 17:34
언제나 그렇듯 병렬도 기초가 젤 중요한 듯해요.

예전에 빵집 개발자 분께서 "개발자는 자기가 아는 범위 안에서 상상한다"(정확한 문구는 아닐 거고 대충 그런 취지였던 것 같습니다.) 라고 하셨는데, 전적으로 동의합니다.

병렬 처리에 대해서 공부를 하면 전에는 몰라서 개선 포인트라고 생각치 못했던 부분들이 개선 포인트로 보이기 시작합니다.

하지만 병렬 프로그램은 "선무당이 사람잡는" 최고의 케이스이기도 합니다. 잘못 걸어놓은 락 등으로 서비스 망가지기 참 쉽죠. 게다가 그걸 만든 선무당은 그런 현상을 보통 이해하지 못합니다. 병렬에서 테스트를 특히 강조하는 이유가 그겁니다.

jmeter 등을 이용하여 풀테스트를 해보시길 권장합니다. 쓰레드를 마구 띄우는 테스트 프로그램을 작성하셔도 좋구요. 제가 예전에 올려놓은 ThreadLocalTimer 같은 거 써서 병목 부분을 찾아내시는 거 강추합니다. 로그를 아낌없이 찍어주면서 찾아내야 합니다.

개발에 있어서 문제해결 중 cost 의 95%는 문제점 찾기라고 생각합니다.

언제나 그렇듯 저는 깊이 없이 기초만 다룰 생각입니다. 기초 속에 모든 것이 들어있나니~~ㅋㅋ
Commented by 용식 at 2011/11/01 21:17
아는만큼 보인다랑 비슷한 맥락인 것 같네요 :)
전적으로 동의합니다. 기초가 제일 중요하다는것... ㅎㅎㅎ

:         :

:

비공개 덧글

◀ 이전 페이지          다음 페이지 ▶