태터데스크 관리자

도움말
닫기
적용하기   첫페이지 만들기

태터데스크 메시지

저장하였습니다.
2017.05.05 08:30


 [Java Concurrency] 명시적인 락


barging, condition, contended performance, CPU, Fair, Finally, hand-over-hand locking, Interface, InterruptedException, java concurrency, jvm, LinkedList, LOCK, lock coupling, lock striping, lock 사용해야 하는 경우, lockinterruptibly, mutual exclusion, newcondition, nonfair, readlock, ReentrantLock, ReentrantReadWriteLock, semaphore, synchronized, system call, timeout, try-catch, try-finally, trylock, unconditional lock, Unlock, writelock, [Java Concurrency] 명시적인 락, 간편, 경쟁 성능, 고급 동기화, 고려 사항, 공유 메모리 버스, 공정, 공정성, 관리 인터페이스, 기본값, 다운 그레이드, 단점, 대기 상태, 데드락, 두개의 try 문, 디버깅, 디버깅 인터페이스, 락, 락 능력, 락 세분화, 락 스트라이핑, 락 커플링, 락 해제, 락 해제 방법, 락 확보, 메모리 가시성, 메모리 동기화 트래픽, 메모리 측면, 메소드, 명시적인 락, 모니터링 인터페이스, 병렬성, 복잡도, 불공정, 블락 구조, 블로킹 메소드, 블록을 벗어나는 구조의 락, 상호 배제, 성능, 성능 프로파일링, 세분화, 순서 뛰어넘기, 스레드 덤프, 스케줄링, 스택 프레임, 시간 제한, 시스템 호출, 시한 폭탄, 시한폭탄, 악영향, 암묵적 락, 업그레이드, 업그레이드 데드락, 요청 순서, 위험, 유연성, 인터럽트, 읽기, 읽기 순서 뛰어넘기, 자바5, 자바6, 장점, 재진입 특성, 좋은 조건, 최적화, 컨텍스트 스위치, 코드 구조, 코딩, 타임아웃, 타임아웃 락, 통계, 통계적 공정함, 통제권, 폴링, 폴링 락, 폴링방법, 해시 컬렉션, 핸드 오버 락, 핸드오버락, 혼동


13.1. Lock 과 ReentrantLock


-
Lock 인터페이스는 암묵적인 락과 달리 조건 없는(unconditional)락, 폴링 락, 타임아웃이 있는 락, 락 확보 대기 상태에 인터럽트를 걸 수 있는 방법 등이 포함돼 있으며, 락을 확보하고 해제하는 모든 작업이 명시적이다.


-

public interface Lock{

void lock();

void lockInterruptibly() throws InterruptedException;

boolean tryLock();

boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;

void unlock();

Condition newCondition();

}



-
ReentrantLock 클래스 역시 Lock 인터페이스를 구현하며, synchronized 구문과 동일한 메모리 가시성과 상호 배제 기능을 제공한다.
ReentrantLock 을 확보한다는 것은 synchronized 블록에 진입하는 것과 동일한 효과를 갖고 있고,
ReentrantLock 을 해제한다는 것은 synchronized 블록에서 빠져나가는 것과 동일한 효과를 갖는다.

ReentrantLock 역시 synchronized 키워드와 동일하게 재진입이 가능하도록 허용하고 있다.
ReentrantLock 은 Lock 에 정의돼 있는 락 확보 방법을 모두 지원한다.


-
암묵적인 락은 락을 확보하고자 대기하고 있는 상태의 스레드에는 인터럽트 거는 일이 불가능하고,
대기 상태에 들어가지 않으면서 락을 확보하는 방법 등이 꼭 필요한 상황을 지원할 수 없다.
또한 synchronized 블록이 끝나는 시점에 반드시 해제되도록 돼 있는데, 이런 구조는 코딩하기에 간편하고 예외 처리 루틴과 잘 맞아 떨어지는 구조이긴 하지만 블록의 구조를 갖추지 않은 상황에서 락을 걸어야 하는 경우에는 적용하기가 불가능하다.

명시적인 락은 일부 상황에서 성능과 활동성을 높이기 위해 유연성이 높은 락 방법을 제공해준다.


-
Reentrant 락의 기본 사용 방법.

Lock lock = new ReentrantLock();

...

lock.lock();

try{

...

} finally{

lock.unlock();

}



-
synchronized 를 사용하는 암묵적인 락보다 좀 복잡한 규칙도 있는데 바로 finally 블록에서 반드시 락을 해제해야 한다는 점이다.
락을 finally 블록에서 해제하지 않으면 try 구문 내부에서 예외가 발생했을 때 락이 해제되지 않는 경우가 발생한다.
락을 사용할 때 try 블록 내부에서 예외가 발생했을 때 어떤 일이 발생할 수 있는지에 대해 반드시 고민해봐야 한다.
만약 예외 때문에 해당 객체가 불안정한 상태가 될 수 있다면 try-catch 구문이나 try-finally 구문을 추가로 지정해 안정적인 상태를 유지하도록 해야 한다.


-
락을 해제하는 기능을 finally 구문에 넣지 않은 코드는 언제 터질지 모르는 시한폭탄과 같다.

synchronized 구문을 제거하는 대신 기계적으로 ReentrantLock 으로 대치하는 작업을 하지 말아야 하는 이유이다.

ReentrantLock 을 사용하면 해당하는 블록의 실행이 끝나고 통제권이 해당 블록을 떠나는 순간 락을 자동으로 해제하지 않기 때문에 굉장히 위험한 코드가 될 가능성이 높다.
락을 블록이 끝나는 시점에 finally 블록을 사용해 해제해야 한다는 사실은 행동으로 지키기 어렵지 않을 뿐더러 절대 잊어서는 안 되는 일이기도 하다.



* 13.1.1. 폴링과 시간 제한이 있는 락 확보 방법

-
락을 확보할 때 시간 제한을 두거나 폴링을 하도록 하면 다른 방법, 즉 확률적으로 데드락을 회피할 수 있는 방법을 사용할 수 있다.
락을 확보할 때 시간 제한을 두거나 폴링 방법(tryLock)을 사용하면 락을 확보하지 못하는 상황에서도 통제권을 다시 얻을 수 있으며, 그러면 미리 확보하고 있던 락을 해제하는 등의 작업을 처리한 이후 락을 다시 확보하도록 재시도할 수 있다.


-
일정시간 이내에 실행돼야 하는 코드에서 대기 상태에 들어갈 수 있는 블로킹 메소드를 호출해야 한다면
지정된 시간에서 현재 남아있는 시간만큼을 타임아웃으로 지정할 수 있겠다.
그러면 지정된 시간 이내에 결과를 내지 못하는 상황이 되면 알아서 기능을 멈추고 종료되도록 만들 수 있다.

반면 암묵적인 락을 사용했다면 일단 락을 확보하고자 시도하게 되면 멈출 수가 없기 때문에
정해진 시간 안에 처리해야 하는 작업을 맡기기엔 위험도가 높다.



* 13.1.2. 인터럽트 걸 수 있는 락 확보 방법

-
암묵적인 락을 확보하는 것과 같은 작업은 인터럽트에 전혀 반응하지 않는다.
인터럽트에 전혀 반응하지 않는 방법밖에 없다면 작업 도중 취소시킬 수 있어야만 하는 기능을 구현할 때 굉장이 복잡해진다.
lockInterruptibly 메소드를 사용하면 인터럽트는 그대로 처리할 수 있는 상태에서 락을 확보한다.


-
인터럽트에 대응할 수 있는 방법으로 락을 확보하는 코드의 구조는 일반적으로 락을 확보하는 모습보다 약간 복잡하긴 한데,
두 개의 try 구문을 사용해야 한다.
( 인터럽트를 걸 수 있는 락 확보 방법에서 InterruptedException 예외를 던질 수 있도록 돼 있다면 표준적인 try-finally 락 구조를 그대로 사용할 수 있다. )


-
타임아웃을 지정하는 tryLock 메소드 역시 인터럽트를 걸면 반응하도록 돼 있으며,
인터럽트를 걸어 취소시킬 수도 있어야 하면서 동시에 타임아웃을 지정할 수 있어야 한다면
tryLock 을 사용하는 것만으로 충분하다.



* 13.1.3. 블록을 벗어나는 구조의 락

-
복잡한 구조의 프로그램에 락을 적용해야 할 때는 훨씬 유연한 방법으로 락을 걸 수 있어야 한다.
락을 적용하는 코드를 세분화할수록( 예: 락 스트라이핑(lock striping) ) 앱의 확장성이 얼마나 높아질 수 있는지에 대해 봤다.

linked list 역시 해시 컬렉션과 마찬가지로 락을 세분화할 수 있는데
각각의 개별 노드마다 서로 다른 락을 적용할 수 있다.

특정 노드에 대한 락은 해당 노드가 갖고 있는 링크 포인터와 실제 값을 보호한다.
링크를 따라가는 알고리즘을 실행하거나 리스트 연결 구조를 변경할 때는 특정 노드에 대한 락을 먼저 확보하고,
그 노드에 연결된 다른 노드에 대한 락을 확보한 다음, 원래 노드에 대한 락을 해제해야 한다.
이런 방법은 핸드 오버 락(hand-over-hand locking) 또는 락 커플링(lock coupling) 이라고 부른다.



13.2. 성능에 대한 고려 사항


-
자바 5.0 에서 ReentrantLock 이 처음 소개됐을 때 암묵적인 락에 비해 훨씬 나은 경쟁 성능(contended performance) 을 보여줬다.
락과 그에 관련한 스케줄링을 관리하느라 컴퓨터의 자원을 많이 소모하면 할수록
실제 앱이 사용할 수 있는 자원은 줄어들 수밖에 없다.
좀 더 잘 만들어진 동기화 기법일수록 시스템 호출을 더 적게 사용하고,
컨텍스트 스위치 횟수를 줄이고, 공유된 메모리 버스에 메모리 동기화 트래픽을 덜 사용하도록 하고,
시간을 많이 소모하는 작업을 줄여주며, 연산 자원을 프로그램에서 우회시킬 수도 있다.


-
자바 6에서는 암묵적인 락을 관리하는 부분에 ReentrantLock 에서 사용하는 것과 같이 좀 더 향상된 알고리즘을 사용하며,
그에 따라 확장성에서 큰 차이나 나던 것이 많이 비슷해졌다.


-
성능과 확장성은 모두 CPU 의 종류, CPU 의 개수, 캐시의 크기, JVM 의 여러 가지 특성 등에 따라 굉장히 민감하게 바뀐다.
성능과 확장성에 영향을 주는 여러 가지 요인은 시간이 지나면서 계속해서 바뀌게 마련이다.


-
성능 측정 결과는 움직이는 대상이다.
바로 어제 X 가 Y 보다 빠르다는 결과를 산출했던 성능 테스트를 오늘 실행해보면 다른 결과를 얻을 수도 있다.





13.3. 공정성


-
ReentrantLock 클래스는 두 종류의 공정성 설정을 지원한다.
하나는 불공정(nonfair) 락 방법이고, 다른 하나는 공정(fair) 한 방법이다. ( 기본은 불공정 )
공정한 방법을 사용할 때는 요청한 순서를 지켜가면서 락을 확보하게 된다.
반면 불공정한 방법을 사용하는 경우에는 순서 뛰어넘기 ( barging ) 가 일어나기도 하는데,
락을 확보하려고 대기하는 큐에 대기 중인 스레드가 있다 하더라도 해제된 락이 있으면 대기자 목록을 뛰어 넘어 락을 확보할 수 있다.
( Semaphore 클래스 역시 공정하거나 불공적한 방법을 사용하도록 설정할 수 있다. )

그렇다고 불공정한 ReentrantLock 이 일부러 순서를 뛰어넘도록 하지는 않으며,
대신 딱 맞는 타이밍에 락이 해제된다 해도 큐의 뒤쪽에 있어야 할 스레드가 순서를 뛰어넘지 못하게 제한하지 않을 뿐이다.


-
락을 관리하는 입장에서 봤을 때 공정하게만 처리하다 보면 스레드를 반드시 멈추고 다시 실행시키는 동안에 성능에 큰 지장을 줄 수 있다.
실제로 보면 통계적인 공정함(대기 상태에 들어간 스레드는 언젠가는 반드시 락을 확보할 수 있다.) 정도만으로도 충분히 괜찮은 결과를 얻을 수 있고, 그와 더불어 성능에도 훨씬 악영향이 적다.


-
대부분의 경우 공정하게 순서를 관리해서 얻는 장점보다 불공정하게 처리해서 얻는 성능상의 이점이 크다


-
스레드 간의 경쟁이 심하게 나타나는 상황에서 락을 공정하게 관리하는 것보다 불공정하게 관리하는 방법의 성능이 훨씬 빠른 이유는 대기 상태에 있던 스레드가 다시 실행 상태로 돌아가고 또한 실제로 실행되기까지는 상당한 시간이 걸리기 때문이다.


-
공정한 방법으로 락을 관리할 때는 락을 확보하고 사용하는 시간이 상대적으로 길거나 락 요청이 발생하는 시간 간격이 긴 경우에 유리하다.
락 사용 시간이 길거나 요청 간의 시간 간격이 길면 순서 뛰어넘기 방법으로 성능상의 이득을 얻을 수 있는 상태,
즉 락이 해제돼 있는 상태에서 다른 스레드가 해당 락을 확보하고자 대기 상태에서 깨어나고 있는 상태가 상대적으로 훨씬 덜 발생하기 문이다.


-
기본 ReentrantLock 과 같이 암묵적인 락 역시 공정성에 대해 아무런 보장을 하지 않는다.
하지만 통계적으로 공정하다.



13.4. synchronized 또는 ReentrantLock 선택


-
ReentrantLock 은 락 능력이나 메모리 측면에서 synchronized 블록과 동일한 형태로 동작하면서도
락을 확보할 때 타임아웃을 지정하거나 대기 상태에서 인터럽트에 잘 반응하고 공정성 여부를 지정할 수도 있으며
블록의 구조를 갖추고 있지 않은 경우에도 락을 적용할 수 있는 유연함을 갖고 있다.

ReentrantLock 을 사용했을 때의 성능이 synchronized 를 사용했을 때보다 낫다고 판단되는데,
자바 5에서는 아주 큰 차이로 성능이 앞서지만
자바 6에서는 그다지 큰 차이가 있지는 않다.


-
일부 책이나 자료를 보면 이미 synchronized 블록을 "낡은" 방법이라고 보고
ReentrantLock 을 무조건 사용하라고 권장하는 경우가 있다.
하지만 아직은 ReentrantLock 의 장점을 너무 좋게 평가한 것이 아닐까 생각된다.

암묵적인 락은 여전히 명시적인 락에 비해서 상당한 장점을 갖고 있다.
코드에 나타나는 표현 방법도 훨씬 익숙하면서간결하고,
현재 만들어져 있는 대다수의 프로그램이 암묵적인 락을 사용하고 있으니
암묵적인 락과 명시적인 락을 섞어 쓴다고 하면 코드를 읽을 때 굉장히 혼동될 뿐만 아니라
오류가 발생할 가능성도 더 높아진다.

분명히 ReentrantLock 은 암묵적인 락에 비해 더 위험할 수도 있다.
만약 finally 블록에 unlock 메소드를 넣어 락을 해제하도록 하지 않는다면 일단 프로그램이 제대로 동작하는듯 싶다가도 어디에선가 언젠가 분명히 터지고야 말 시한 폭탄을 심어두는 셈이다.

ReentrantLock 은 synchronized 블록에서 제공하지 않는 특별한 기능이 꼭 필요할 때만 사용하는 편이 안전하다고 본다.


-
ReentrantLock 은 암묵적인 락만으로는 해결할 수 없는 복잡한 상황에서 사용하기 위한 고급 동기화 기능이다.
다음과 같은 고급 동기화 기법을 사용해야 하는 경우에만 reentrantLock 을 사용하도록 하자.
    1) 락을 확보할 때 타임아웃을 지정해야 하는 경우.
    2) 폴링의 형태로 락을 확보하고자 하는 경우.
    3) 락을 확보하느라 대기 상태에 들어가 있을 때 인터럽트를 걸 수 있어야 하는 경우.
    4) 대기 상태 큐 처리 방법을 공정하게 해야 하는 경우
    5) 코드가 단일 블록의 형태를 넘어서는 경우
그 외의 경우에는 synchronized 블록을 사용하도록 하자.


-
자바 5에서는 synchronized 블록이 ReentrantLock 에 비해 갖고 있는 장점이 하나 더 있다.
스레드 덤프를 떠보면 어느 스레드의 어느 메소드에서 어느 락을 확보하고 있고,
데드락에 걸린 스레드가 있는지, 어디에서 데드락에 걸렸는지도 표시해준다.
반면 JVM 입장에서는 ReentantLock 이 어느 스레드에서 사용됐는지를 알 수 없기 때문에 동기화 관련 문제가 발생했을 때 JVM 을 통해서 문제를 해결하는 데 도움이 될 정보를 얻기가 어렵다.

자바 6에서는 ReentrantLock 의 이런 장점이 해소됐는데, 락이 등록할 수 있는 관리 및 모니터링 인터페이스가 추가됐다.
락을 관리 및 모니터링 인터페이스에 등록하고 나면, 스레드 덤프에서 ReentrantLock 의 상황을 알 수 있을 뿐만 아니라 외부의 관리나 디버깅 인터페이스를 통해 락의 움직임을 확인할 수도 있다.

디버깅에 활용할 수 있었다는 건 synchronized 가 잠깐 동안이라도 가졌던 약간의 장점이긴 했다.


-
암묵적인 락을 사용할 때는 항상 특정 스택 프레임에 락이 연관돼 있었지만,
ReentrantLock 은 블록을 벗어나는 범위에도 사용할 수 있으며 따라서 특정 스택 프레임에 연결되지 않는다.


-
좀 더 성능이 최적화되면 synchronized 를 사용해도 ReentrantLock 보다 성능이 더 나아지지 않을까 기대해본다.
특히 synchronized 구문은 JVM 내부에 내장돼 있기 때문에 ReentrantLock 에 비해서 여러 가지 최적화를 적용하기가 쉽다.
예를 들어 스레드에 한정된 락 객체를 대상으로는 락 생략 기법을 적용할 수 있고,
락 확장 기법을 적용해 암묵적인 락으로 동기화된 부분에서 락을 사용하지 않도록 할 수도 있다.



13.5. 읽기-쓰기 락


-
ReentrantLock 은 표준적인 상호 배제(mutual exclusion) 락을 구현하고 있다.
즉 한 시점에 단 하나의 스레드만이 락을 확보할 수 있다. 하지만 이와 같은 상호 배제 규칙은 일반적으로 데이터의 완전성을 보장하는 데 충분한 정도를 넘어서는 너무 엄격한 특징을 갖고 있다.
따라서 병렬 프로그램의 장점을 필요 이상으로 제한하기도 한다.
상호 배제 규칙은 다시 말하자면 너무 보수적인 규칙이며, 쓰기 연산과 쓰기 연산이 동시에 일어나거나 쓰기와 읽기 연산이 동시에 일어나는 경우를 제한할 뿐만 아니라
읽기와 읽기 연산이 동시에 일어나는 경우도 제한한다.
그런데 대부분의 경우 사용하는 데이터 구조는 읽기 작업이 많이 일어난다.


-
해당 데이터 구조를 사용하는 모든 스레드가 가장 최신의 값을 사용하도록 보장해주고,
데이터를 읽거나 보고 있는 상태에서는 다른 스레드가 변경하지 못하도록 하면 아무런 문제가 없겠다.
즉 읽기 작업은 여러 개를 한꺼번에 처리할 수 있지만 쓰기 작업은 혼자만 동작할 수 있는 구조의 동기화를 처리해주는 락이 바로 읽기-쓰기 락(read-write lock)이다.


-

public interface ReadWriteLock{
    Lock readLock();

Lock writeLock();

}



-
ReadWriteLock 은 특정 상황에서 병렬 프로그램의 성능을 크게- 높일 수 있도록 최적화된 형태로 설계된 락이다.
실제로 멀티 CPU 시스템에서 읽기 작업을 많이 사용하는 데이터 구조에 ReadWriteLock 을 사용하면 성능을 크게 높일 수 있다.
ReadWriteLock 은 구현상의 복잡도가 약간 높기 때문에 최적화된 상황이 아닌 곳에 적용하면
상호 배제시키는 일반적인 락에 비해서 성능이 약간 떨어지기도 한다.
특정 상황을 놓고 ReadWriteLock 을 사용하는 것이 적절한 것인지에 대한 대답은 성능 프로파일링을 통해서만 얻을 수 있다.
또한 ReadWriteLock 역시 읽기와 쓰기 작업을 동기화하는 부분에 Lock 을 사용하기 때문에 성능을 측정해봤을 때 ReadWriteLock 이 더 느리다고 판단되면 손쉽게 ReadWriteLock 을 걷어내고 일반 Lock 을 사용하도록 변경할 수 있다.


-
ReadWrietLock 을 구현할 때 적용할 수 있는 특성에는 다음과 같은 것이 있다.

락 해제 방법
    쓰기 작업에서 락을 해제했을 때 대기 큐에 읽기 작업뿐만 아니라 쓰기 작업도 대기중이었다고 하면
    누구에게 락을 먼저 넘겨줄 것인가의 문제

읽기 순서 뛰어넘기
    읽기 작업에서 락을 사용하고 있고 대기 큐에 쓰기 작업이 대기하고 있다면,
    읽기 작업이 추가로 실행됐을 때 읽기 작업을 그냥 실행할 것인지? 아니면 대기 큐의 쓰기 작업 뒤에 대기하도록 할 것인지

재진입 특성
    읽기 쓰기 모두 재진입 가능한지

다운 그레이드
    특정 스레드에서 쓰기 락을 확보하고 있을 때, 쓰기락을 해제하지 않고 읽기 락을 확보할 수 있는지?

업 그레이드
    읽기 락을 확보하고 있는 상태에서 쓰기 락을 확보하고자 할 때 대기 큐에 들어 있는 다른 스레드보다 먼저 쓰기 락을 확보하게 할 것인가?
    ReadWriteLock 을 구현하는 대부분의 경우 업그레이드를 지원하지 않는다.


-
ReentrantReadWriteLock 클래스를 사용하면 읽기 락과 쓰기 락 모두에게 재진입 가능한 락 기능을 제공한다.
ReentrantReadWriteLock 역시 ReentrantLock 처럼 공정성 여부도 지정할 수 있다.( 기본 값을 불공정 )
공정하게 설정한 락을 사용하는 경우 대기 큐에서 대기한 시간이 가장 긴 스레드에게 우선권이 돌아가는데,
읽기 락을 확보하고 있는 상태에서 다른 스레드가 쓰기 락을 요청하는 경우,
쓰기 락을 요청한 스레드가 쓰기 락을 확보하고 해제하기 전에는 다른 스레드에서 읽기 락을 가져가지 못한다.
불공정하게 설정된 락을 사용하면 어느 스레드가 락을 가져가게 될지 알 수 없다.
쓰기 락을 확보한 상태에서 읽기 락을 사용하는 다운그레이드는 허용되며,
읽기 락을 확보한 상태에서 쓰기 락을 사용하는 업그레이드는 제한된다.
( 업그레이드를 시도하면 데드락이 발생한다. )


-
ReentrantLock 과 동일하게 ReentrantReadWriteLock 역시 쓰기 락을 확보한 스레드가 명확하게 존재하며,
쓰기 락을 확보한 스레드만이 쓰기 락을 해제할 수 있다.
자바 6에서는 어느 스레드가 읽기 락을 확보했는지 추적하도록 돼 있다.


-
읽기-쓰기 락은 락을 확보하는 시간이 약간은 길면서 쓰기 락을 요청하는 경우가 적을 때 병렬성(concurrency)을 크게 높여준다.



Summary


명시적으로 Lock 클래스를 사용해 스레드를 동기화하면 암묵적인 락보다 더 많은 기능을 활용할 수 있다.
예를 들어 락을 확보할 수 없는 상황에 유연하게 대처하는 방법이나 대기 큐에서 기다리는 방법과 규칙도 원하는 대로 정할 수 있다.
그렇다고 해서 synchronized 구문 대신 기계적으로 ReentrantLock 을 사용해야 할 필요는 없고,
단지 ReentrantLock 에서만 제공되고 synchronized 구문은 제공하지 않는 동기화 관련 기능이 꼭 필요한 경우에만 ReentrantLock 을 사용하도록 하자.

읽기-쓰기 락을 사용하면 읽기 작업만 처리하는 다수의 스레드는 동기화된 값을 얼마든지 동시에 읽어갈 수 있다.
따라서 읽기 작업이 대부분인 데이터 구조에 읽기-쓰기 락을 사용하면 확장성을 높여주는 훌륭한 도구가 된다.


더보기



댓글을 달아 주세요


Posted by 돼지왕왕돼지
TAG, , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , ,