Contents

조회 수 11700 댓글 0
Atachment
첨부 '1'
?

단축키

Prev이전 문서

Next다음 문서

크게 작게 위로 아래로 댓글로 가기 인쇄
?

단축키

Prev이전 문서

Next다음 문서

크게 작게 위로 아래로 댓글로 가기 인쇄

thread 를 이용한 다중클라이언트 연결서버 제작

윤 상배

dreamyun@yahoo.co.kr


차례
1절. 소개
2절. Thread 네트웍 프로그래밍
2.1절. Thread 를 이용함으로써 얻는 이익과 손해
2.2절. 쓰레드를 이용한 네트웍 서버 프로그래밍
2.2.1절. 기능정의
2.2.2절. zipcode_thread.c
2.2.3절. thread_mon.c
2.3절. 테스트
3절. 결론

1절. 소개

지금까지 fork(),select(),poll() 을 이용한 다중클라이언트를 받아들이는 서버제작에 다루었었다. 이번은 그중 마지막으로 thread 를 이용한 서버제작을 아룰것이다. thread 의 구현을 위해서는 pthread 를 이용 하도록 하겠다.

물론 이들 fork, select, poll, thread 외에도 몇가지 (좀더 진보된 형태의) 다중 클라이언트 서버를 위한 방법들이 있으나 나중에 다루도록 일단은 논외로 하겠다.

여기에서는 쓰레드의 개념과 각종 API 에 대한 설명은 하지 않을것이다. 이미 이 사이트에서 몇번에 걸쳐서 다루고 있음으로 쓰레드에 대한 개념이 충분히 잡혀있지 않다면 먼저 이들 문서에 대해서 읽어보기 바란다.

또한 이문서는 자료구조의 간단한 구현을 위해서 STL 을 사용하고 있으며, IPC 를 위한 Unix Domain Socket 도 사용하고 있다. STL 과 도메인 소켓을 잘 모른다면 역시 먼저 이들 관련 문서를 읽어 보기 바란다.


2절. Thread 네트웍 프로그래밍

Thread 를 이용한다고 해서, fork, select, poll 과 구별되는 어떤 기술을 필요로 하는건 아니다. 구현 기본원리는 동일하며 여기에 단지 몇가지 Thread API 가 들어갈 뿐이다.

다른 것들과 마찬가지로 Thread 역시 최초 socket() 함수를 호출해서 endpoint(접점) 소켓을 생성하고accept()로 endpoint 소켓으로 연결이 있는지 확인후 연결이 있다면 새로운 쓰레드를 생성하는 방식으로 프로그래밍이 이루어진다. 전체적으로 봤을때 fork 와 특히 유사하다. 다른점이라면 accept 한후에 frok 대신 thread 를 이용해서 해당 연결에 대한 처리를 한다는 점 정도가 될것이다.

            +--------+   
            | Start  |  ㅣ
            +--------+  ㅣ
            +--------+  ㅣ
            | Socket |  ㅣ
            +--------+  ↓
            +--------+
            | Bind   |
            +--------+
            +--------+
            | Listen |
            +--------+
            +--------+
            | Accept | <------+
            +--------+        | polling
                |             |
        +---------------+     |
        | Thread Create | ----+
        +---------------+
                |
            +--------+
            |        |
         +------+  +------+
         | TH 1 |  | TH 2 | .....
         +------+  +------+
            |         |
           END       END
		

소켓의 생성과 연결 기다림은 전형적인 Socket -> Bind -> Listen -> Accept 의 순서를 따른다. Accept 에 만약 새로운 연결이 리턴되면, pthread_create 를 이용해서 새로운 쓰레드를 생성시키고, 해당클라이언트와 통신하게 된다.


2.1절. Thread 를 이용함으로써 얻는 이익과 손해

리눅스에서 사용하는 pthread 는 clone() 함수를 이용해서 구현된다. clone() 함수는 fork(2) 와 마찬가지로 새로운 프로세스를 생성한다. 하지만 fork 와는 달리 파일기술자, 시그널 핸들러 외에도 실행문맥과 전역메모리 등을 공유하게 된다. 많은 부분들을 서로 공유하게 됨으로써, 완전히 새로운 프로세스가 생성되는 fork 에 비해서 좀더 적은 비용을 들이며서 프로세서의 생성이 가능하다. 이러한 생성속도의 차이는 대부분의 경우 무시할만 한 수준이지만, 요즘처럼 인터넷이 보편화된 시점에서는 무시할수 없는 상황이 발생하기도 한다.

그러나 역시 쓰레드를 이용함으로써 현실적으로 느낄수 있는 가장큰 잇점은 IPC 의 사용을 줄일수 있다라는 점이다. fork 를 사용할경우 부모프로세스와 자식프로세스의 통신을 위해서, pipe, fifo, 공유메모리, 메시지큐등 상황에 따라서 다양한 종류의 IPC 를 선택해서 사용해야 하는데, 이들을 제대로 사용하기 위해선 상당히 까다로운 프로그래밍 기법을 요구한다. 그러나 Thread 는 많은 부분을 서로 공유하게 됨으로 공유되는 영역에 값을 변경하는 정도만으로 충분히 각 쓰레드간 통신이 가능해진다.

반면 단점이 있는데, 우선 문맥을 공유하게 됨으로써 하나의 쓰레드가 잘못 작동하게 될경우 모든 프로세스가 죽어버리는 문제가 발생할수 있다. fork 를 이용한 프로세스의 모델의 경우 하나의 프로세스가 죽는다 하더라도 다른 프로세스에 영향을 미치지 않는것과는 대조적이다.

사실 프로세스가 죽는 문제는 유저의 잘못된 입력등을 처리하지 못하거나 또는 예외상황에 대한 처리를 하지 않아서 일어나는 경우도 있을수 있다. 이는 꽤 심각할수 있는데, (차라리 코딩을 잘못해서 발생되는 오류는 대부분의 경우 빨리 발견됨으로 그리 심각하지 않을수 있다.) 현재 약 100 명의 유저가 들어와서 작업을 하는중 단 한명의 유저가 잘못된 입력을 하고 때문에 쓰레드가 죽었고, 해당 쓰레드만 죽는게 아니고 다른 정상작동 하고 있는 쓰레드까지 몽땅 죽어버리는 심각한 사태가 벌어질수 있기 때문이다. 이른바 내부 테스트를 할때는 잘 돌아가던 프로그램이 서비스만 하면 죽어나가는 경우의 프로그램을 만들어 낼수 있다. 내부 테스트 할때야 테스트 환경에 맞추어서 정형화된 입/출력을 수행하니 문제가 발생할 일이 없지만 실제 서비스를 하게 되면 온갖 다양한 입력을 받아야 하기 때문이다.

그러므로 쓰레드 프로그래밍을 할때는 이것 저것 꼼꼼히 따져서 작업을 해야한다. 또한 쓰레드가 조금만 복잡하게 얽혀 있어도 문맥을 교환한다는 특징때문에 어느 쓰레드에서 어떤 문제가 발생했는지 알아내기 어렵다는 단점을 가진다. 이는 디버깅의 어려움으로 이어진다.

하지만 쓰레드 프로그래밍에 대한 충분한 경험과 이해가 있다면, 고성능의 프로그램을 좀더 쉽게 개발할수 있다.


2.2절. 쓰레드를 이용한 네트웍 서버 프로그래밍

만들고자 하는 프로그램은 여러번 다루었던 "우편번호 검색" 프로그램이다. 다중연결서버 만들기 (1) 에 있는 서버프로그램을 쓰레드버전으로 제작성하게 될것이다.


2.2.1절. 기능정의

비록 그전에 사용되었던 우편번호 검색 프로그램을 수정하기는 하겠지만 단지 수정만 해서는 너무 심심할것 같아서 몇가지 부가적인 기능을 추가했다.

여기에는 3개의 프로그램이 들어간다. 클라이언트 프로그램과 하나의 서버프로그램 그리고 서버프로그램 상태를 모니터링 할수 있는 모니터링 프로그램이다. 클라이언트 프로그램은 그전에 사용했던 프로그램을 수정없이 그대로 사용할것이다.

위에서 보면 새로 추가되는 모니터링 프로그램이라는게 있다. 이 프로그램은 실행시킬 경우 현제 서버에 붙어 있는 클라이언트의 주소, 포트, 접속시간 정보등을 가져와서 출력시켜주게 된다.

그러기 위해서는 서버프로그램은 클라이언트의 접근과 연결해제가 있을때마다 이 정보를 체크해서 메모리상에 저장하고 있다가 모니터링 프로그램이 요청할경우 모니터링 프로그램에게 전송시켜 줘야 할것이다.

S  : Inet socket
|| : Domain socket
                 +-------------------+
                 | Client Info Table |
                 +-------------------+
                          | | 
                          | | 
+--------+            +--------+                 +---------+
| Client | ----S----> | SERVER | ------||------> | Moniter | ---> STDOUT 
+--------+            +--------+                 +---------+
				
위의 그림은 대략의 구성도이다. 서버는 client info table 을 유지하고 있다가 moniter 로부터 요청이 오면 이 정보를 전달한다. 전달을 위해서는 IPC 가 사용되어야 하는데, 그중 Domain socket 를 사용할것이다.

Client info table 을 유지하기 위한 자료구조로 STL의 multimap 을 사용할것이다. key 로는 Client 의 주소(32bit long), 값으로는 Client 의 port 번호, 접속시간, 주소값 등을 가지고 있는 구조체가 된다.

multimap
				
map 대신 multimap 을 사용한 이유는 하나의 주소에서 여러개의 연결이 이루어질수 있기 때문이다. 그런데 map 의 경우 중복되는 key 값을 가지고 있을수 없으므로 이 자료구조를 유지하기 위해서는 적합하지않다. 실제 Client 의 정보를 삭제하거나 할경우에는 주소값과 포트값을 동시에 이용해서 일치되는 값을 삭제 하게 될것이다. - 주소값과 포트값을 이용할경우 모든 연결에 대해서 유일한 값을 가지도록 할수 있다 -


2.2.2절. zipcode_thread.c

서버프로그램이다. STL 을 사용하고 있음으로 g++ 컴파일러를 사용해서 컴파일해야 한다. 그러나 c코딩 형식을 따르고 있다. 코드는 전혀 아름다움, 효율성등은 추구하지 않았으며, 단지 실행가능한 수준이 되도록만 코딩되어있다. 이해하기 까다로운 코드는 아님으로 주석으로 설명을 대신하도록 하겠다.

예제 : zipcode_thread.c

#include <sys/stat.h> 
#include <time.h> 
#include <sys/socket.h> 
#include <signal.h> 
#include <unistd.h> 
#include <netinet/in.h> 
#include <arpa/inet.h> 
#include <sys/un.h> 
#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <pthread.h> 
#include <vector> 
#include <string> 
#include <map> 

#define SOCKFILE "/tmp/zipsock" 

// ------------------------------
// 전역 자료들 
// ------------------------------
typedef struct clientdata
{
    unsigned long int addr; 
    int port;
    int start_time;
} clndata;
vector vaddress;
multimap<unsigned long int, clndata> clnmondata; 

// 쓰레드 전역 함수들 
void *thread_comm(void *);
void *server_mon(void *);

// 일반 전역 함수들
void clientinfo_erase(unsigned long int addr, int sockfd);
int load_address();

int main(int argc, char **argv)
{
    pthread_t p_thread;
    struct sockaddr_in clientaddr, serveraddr;
    char th_data[256];

    int server_sockfd, client_sockfd, client_len; 

    memset(th_data, 0x00, 256);

    if (argc != 2)
    {
        printf("Usage : ./zipcode [port]
");
        printf("예    : ./zipcode 4444
");    
        exit(0);
    }

    // 우편주소가 입력되어 있는 파일로부터 
    // 데이타를 읽어와서 메모리에 적재한다.  
    load_address();

    if ((server_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
    {
        perror("socket error : ");
        exit(0);
    }

    bzero(&serveraddr, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons(atoi(argv[1]));

    if (bind(server_sockfd, (struct sockaddr *)&serveraddr, 
             sizeof(serveraddr)) == -1)
    {
        perror("bind error : ");
        exit(0);        
    }
    if (listen(server_sockfd, 5) == -1)
    {
        perror("bind error : ");
        exit(0);    
    }

    // 서버모니터를 위한 쓰레드 생성
    if (pthread_create(&p_thread, NULL, server_mon, (void *)NULL) == -1)  
    {
        perror("UDP CREATE ERROR : ");
        exit(0);
    }

    // Client 의 연결을 받아들인다. 
    // 새로운 Client 가 들어오면 쓰레드를 생성시킨다.  
    // 이때 쓰레드 함수아규먼트로 Client 소켓지시자와 struct sockaddr_in 
    // 정보를 넘긴다.    
    while(1)
    {
        client_len = sizeof(clientaddr);
        client_sockfd = accept(server_sockfd, (struct sockaddr *)&clientaddr, 
                                (socklen_t *)&client_len);

        // 쓰레드로 넘길 정보를 만든다. 
        //  0                    .... 255
        //  th_data 의 구조 
        // +------+------------------+-----+
        // |sockfd|struct sockaddr_in|     |
        // +------+------------------+-----+
        memcpy(th_data, (void *)&client_sockfd, sizeof(client_sockfd));
        memcpy(th_data+sizeof(client_sockfd), (void *)&clientaddr, client_len); 

        // 쓰레드 생성
        if (pthread_create(&p_thread, NULL, thread_comm, (void *)th_data) == -1)
        {
            perror("thread Create error
");
            exit(0);
        }

        else
        {
            cout << "Thread Create Success" << endl;    
        }
    }    
}

void *thread_comm(void *data)
{
    int sockfd;
    clndata clientinfo; 

    struct sockaddr_in clientaddr; 
    int client_len = sizeof(clientaddr);
    char buf[255];
    vector::iterator mi;
    pthread_t th; 

    // pthread_join 을 하지 않을것임으로 
    // detach 를 해줘서 쓰레드 종료시 
    // 쓰레드 자원을 정리할수 있도록 해줘야 한다.  
    pthread_detach(pthread_self());

    memcpy((void *)&sockfd, (char *)data, sizeof(int)); 
    memcpy((void *)&clientaddr, (char *)data+sizeof(int), client_len); 

    // 클라이언트 정보를 전역 자료에 입력한다. 
    // 입력되는 값은 주소(32bit), 포트, 연결시간이다.  
    clientinfo.addr = clientaddr.sin_addr.s_addr;
    clientinfo.port = ntohs(clientaddr.sin_port);
    time((time_t *)&clientinfo.start_time); 

    // multimap 컨터이너에 클라이언트 정보를 포함시킨다. 
    // key 는 32bit long int 타입의 주소 정보이다.  
    clnmondata.insert(pair<unsigned long int, clndata>(clientinfo.addr, clientinfo));
    while(1)
    {
        // 읽기문제가 생기거나 
        // 혹은 Client 로 부터 Quit 가 전송되었을경우
        // multimap 자료구조에서 문제의 Client 를 삭제시킨다.   
        if (read(sockfd, buf, 255) <= 0)  
        {
            // 문제의 클라이언트를 삭제
            clientinfo_erase(clientinfo.addr, clientinfo.port);
            close(sockfd);
            pthread_exit((void *)NULL);
        }
        if (strncmp(buf, "quit", 4) == 0)
        {

            clientinfo_erase(clientinfo.addr, clientinfo.port);
            write(sockfd, "bye bye", 8);
            close(sockfd);
            pthread_exit((void *)NULL);
        }

        // 그렇지 않을경우 
        // 우편주소 정보에서 일치하는 문자열이 있는지 
        // 찾은 다음 클라이언트에게 전송한다. 
        mi = vaddress.begin(); 
        while(mi != vaddress.end())
        {
            if(strstr(mi->c_str(), buf) != NULL)
            {
                write(sockfd, mi->c_str(), 255);
            }
            *mi++;
        }
        write(sockfd, "end", 255); 
        memset(buf, 0x00, 255); 
    }
}

// zipcode.txt 파일로 부터 
// 주소 데이타를 읽어들인다. 
int load_address()
{
    char line[255];
    FILE *fp;

    if ((fp = fopen("zipcode.txt", "r")) == NULL)
    {
        perror("file open error : ");
        exit(0);
    }

    while(fgets(line, 255, fp) != NULL)
    {
        vaddress.push_back(line);
    }
    fclose(fp);    
    return 1;
}

// 서버모니터 프로그램이다. 
// Domain 연결을 기다리고 있다가 
// 연결이 들어오면 해당 클라이언트에게 
// 현재 활성화된 클라이언트의 정보를 전송한다. 
void *server_mon(void *data)
{
    int sockfd, cl_sockfd;
    socklen_t clilen;
    int buff;

    int end=0; 
    struct sockaddr_un clientaddr, serveraddr;
    multimap<unsigned long int, clndata>::iterator mi;

    unlink(SOCKFILE);
    sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("socket error : ");
        exit(0);
    }

    bzero(&serveraddr, sizeof(serveraddr));
    serveraddr.sun_family = AF_UNIX;
    strcpy(serveraddr.sun_path, SOCKFILE); 

    if (bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) < 0)
    {
        perror("bind error : ");
        exit(0);
    }

    if (listen(sockfd, 5) < 0)
    {
        perror("listen error : ");
        exit(0);
    }

    // 만약에 클라이언트 연결이 있다면 
    // clnmondata(multimap) 자료구조의 내용을 
    // 클라이언트측에 전송한다. 
    while(1)
    {
        int pid;
        int data_size;
        clilen = sizeof(clientaddr);
        cl_sockfd = accept(sockfd, (struct sockaddr *)&clientaddr, &clilen);

        mi = clnmondata.begin();
        data_size = clnmondata.size();
        write(cl_sockfd, (void *)&data_size, sizeof(int));
        while(mi != clnmondata.end())
        {
            write(cl_sockfd, (void *)&mi->second, sizeof(clnmondata)); 
            *mi++;
        }
        close(cl_sockfd);
    }
}

// 유저 정보 구조체 (multimap) 으로부터 
// addr, port 로 일치하는 정보를 
// 삭제한다.  
void clientinfo_erase(unsigned long int addr, int port)
{
    int count ;
    int i;
    multimap<unsigned long int, clndata>::iterator mi;     
    mi = clnmondata.lower_bound(addr);
    count = clnmondata.count(addr);

    if (mi == clnmondata.end())
    {
        cout << "not found " << endl;
    }    
    else
    {
        for(i = 0; i < count;i++)
        {

            if (mi->second.port == port)
            {
                count ++;
                clnmondata.erase(mi);
            }
            *mi++;
        }
    }
}

컴파일 방법은 g++ zipcode_thread zipcode_thread.c -lpthread 이다.


2.2.3절. thread_mon.c

이 프로그램은 전형적인 UDP 도메인 소켓 프로그램임으로 코드 설명은 생략하도록 하겠다.

예제 : thread_mon.c

#include <sys/types.h> 
#include <sys/stat.h> 
#include <sys/socket.h> 
#include <sys/un.h> 
#include <unistd.h> 
#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <fcntl.h> 
#include <time.h> 
#include <netinet/in.h> 
#include <arpa/inet.h> 

#define SOCKFILE "/tmp/zipsock" 

typedef struct clientdata
{
    unsigned long int addr;
    int port;
    int start_time;
} clndata;

int main(int argc, char **argv)
{
    int sockfd;
    struct sockaddr_un serveraddr;
    int  clilen;
    char buff[255];
    int idata = 100;
    int data_size;
    int i;
    struct in_addr st_addr;
    struct tm *tmp;
    char *address;
    clndata data;

    sockfd = socket(AF_UNIX, SOCK_STREAM, 0); 
    if (sockfd < 0)
    {
        perror("exit : ");
        exit(0);
    }

    bzero(&serveraddr, sizeof(serveraddr));
    serveraddr.sun_family = AF_UNIX;
    strcpy(serveraddr.sun_path, SOCKFILE);
    clilen = sizeof(serveraddr);

    if (connect(sockfd, (struct sockaddr *)&serveraddr, clilen) < 0)
    {
        perror("conenct erro : ");
        exit(0);
    }

    read(sockfd, (void *)&data_size, sizeof(int));
    printf("%20s%8s	%-20s
","주소", "포트", "시간");
    printf("====================================================
");
    for(i = 0; i < data_size; i++)    
    {
        read(sockfd, (void *)&data, sizeof(data));
        st_addr.s_addr = data.addr;
        address = inet_ntoa(st_addr);
        tmp = gmtime((time_t *)&data.start_time);  
        printf("%20s%8d	%d/%d %d:%d:%d
", address, data.port, 
                        tmp->tm_mon+1, tmp->tm_mday,
                        tmp->tm_hour, tmp->tm_min, 
                        tmp->tm_sec);
    }
    printf("====================================================
");
    close(sockfd);
    exit(0);
}


2.3절. 테스트

먼저 zipcode_thread 를 띄우고 나서

 
[root@localhost thread]# ./zipcode_thread 4444
			
zipcode_cl 을 띄우면 된다. 이 프로그램은 셈플로알아보는 소켓프로그래밍 에 있는 zipcode_cl.c 를 그대로 컴파일 해서 사용하면 된다.

그리고 thread_mon 을 실행시키면 현제 쓰레드에 접근한 클라이언트의 상황을 화면에 출력해 준다.

[root@localhost thread]# ./thread_mon
                주소    포트    시간                
====================================================
     210.205.210.195   33017    10/15 14:14:41
     210.205.210.195   33019    10/15 14:15:14
====================================================
			


3절. 결론

이상 쓰레드를 이용한 다중 클라이언트 연결서버의 작성에 대해서 알아보았다. 거기에 더불어 이런 저런 간단하지만 재미있는 몇가지의 기능들도 구현해 보았다.

위의 쓰레드 프로그램은 몇가지 수정해야될 것들이 있다. 전역적으로 사용하는 몇가지 쓰레드 공유되는 변수에 대한(multimap) 잠금이 그것이다. 잠금을 하지 않았을경우 다른쓰레드에서 mutimap 원소를 읽고 도중 다른 쓰레드에서 mutimap 의 원소를 삭제하거나 추가한다면, 원하지 않는 결과가 나올수 있기 때문이다. 이것은 mutex 잠금을 통하여 해결할수 있는데 간단히 해결할수 있으니 각자 해결해 보기 바란다.

그리고 시간이 충분하다면 유닉스 도메인 소켓으로 구현된 IPC 부분을 다른 IPC를 사용하도록 변경시켜 보기바란다.

?

List of Articles
번호 분류 제목 글쓴이 날짜 조회 수
319 Develop [c] 격자 직사각형 넓이 구하기 file hooni 2013.04.23 7102
318 Develop [c] 최단거리 알고리즘 & 예제소스.. 13 file hooni 2013.04.23 9685
317 Develop [c] vc++ 에서 clrscr(), gotoxy() 함수 사용하기.. hooni 2013.04.23 11585
316 Develop [c] 오목.. 간단한 소스 ㅋㅋ file hooni 2013.04.23 9000
315 Etc 개발자가 알아야할 10가지 보안팁으로 코드 보호하기 hooni 2013.04.23 15996
314 Develop [c] 오류체크(CRC 체크 ) 소스 2 hooni 2013.04.23 7044
313 Develop [c] 네트워크 정보 알아보기 file hooni 2013.04.23 8372
312 Develop [c] alarm()함수 설명과 간단한 예제 file hooni 2013.04.23 6135
311 Develop [c] selec()를 이용한 입출력 다중화 file hooni 2013.04.23 7860
» Develop [c] 다중연결 서버 만들기 #4 - thread 사용 file hooni 2013.04.23 11700
309 Develop [c] 다중연결 서버 만들기 #3 - poll() 사용 file hooni 2013.04.23 5889
308 Develop [c] 다중연결 서버 만들기 #2 - select() 사용 file hooni 2013.04.23 7058
307 Develop [c] 다중연결 서버 만들기 #1 - fork() 사용 file hooni 2013.04.23 10201
306 Develop [c] 도메인 소켓(Unix Domain Socket) UDP file hooni 2013.04.23 8456
305 Develop [c] 간단한 소켓 프로그래밍 샘플 file hooni 2013.04.23 7735
304 System/OS [linux] X환경 GNOME에서 KDE로 바꾸는 법.. hooni 2013.04.23 11975
Board Pagination Prev 1 ... 46 47 48 49 50 51 52 53 54 55 ... 70 Next
/ 70